hmdl 0.0.1__tar.gz
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-0.0.1/.gitignore +80 -0
- hmdl-0.0.1/LICENSE +21 -0
- hmdl-0.0.1/PKG-INFO +212 -0
- hmdl-0.0.1/README.md +174 -0
- hmdl-0.0.1/pyproject.toml +88 -0
- hmdl-0.0.1/src/hmdl/__init__.py +28 -0
- hmdl-0.0.1/src/hmdl/client.py +196 -0
- hmdl-0.0.1/src/hmdl/config.py +80 -0
- hmdl-0.0.1/src/hmdl/decorators.py +317 -0
- hmdl-0.0.1/src/hmdl/py.typed +1 -0
- hmdl-0.0.1/src/hmdl/types.py +114 -0
hmdl-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
*.egg-info/
|
|
24
|
+
.installed.cfg
|
|
25
|
+
*.egg
|
|
26
|
+
|
|
27
|
+
# PyInstaller
|
|
28
|
+
*.manifest
|
|
29
|
+
*.spec
|
|
30
|
+
|
|
31
|
+
# Installer logs
|
|
32
|
+
pip-log.txt
|
|
33
|
+
pip-delete-this-directory.txt
|
|
34
|
+
|
|
35
|
+
# Unit test / coverage reports
|
|
36
|
+
htmlcov/
|
|
37
|
+
.tox/
|
|
38
|
+
.nox/
|
|
39
|
+
.coverage
|
|
40
|
+
.coverage.*
|
|
41
|
+
.cache
|
|
42
|
+
nosetests.xml
|
|
43
|
+
coverage.xml
|
|
44
|
+
*.cover
|
|
45
|
+
*.py,cover
|
|
46
|
+
.hypothesis/
|
|
47
|
+
.pytest_cache/
|
|
48
|
+
|
|
49
|
+
# Translations
|
|
50
|
+
*.mo
|
|
51
|
+
*.pot
|
|
52
|
+
|
|
53
|
+
# Environments
|
|
54
|
+
.env
|
|
55
|
+
.venv
|
|
56
|
+
env/
|
|
57
|
+
venv/
|
|
58
|
+
ENV/
|
|
59
|
+
env.bak/
|
|
60
|
+
venv.bak/
|
|
61
|
+
|
|
62
|
+
# IDEs
|
|
63
|
+
.idea/
|
|
64
|
+
.vscode/
|
|
65
|
+
*.swp
|
|
66
|
+
*.swo
|
|
67
|
+
*~
|
|
68
|
+
|
|
69
|
+
# mypy
|
|
70
|
+
.mypy_cache/
|
|
71
|
+
.dmypy.json
|
|
72
|
+
dmypy.json
|
|
73
|
+
|
|
74
|
+
# ruff
|
|
75
|
+
.ruff_cache/
|
|
76
|
+
|
|
77
|
+
# OS
|
|
78
|
+
.DS_Store
|
|
79
|
+
Thumbs.db
|
|
80
|
+
|
hmdl-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hmdl-inc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hmdl-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hmdl
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Observability SDK for MCP (Model Context Protocol) servers - Heimdall Platform
|
|
5
|
+
Project-URL: Homepage, https://tryheimdall.com
|
|
6
|
+
Project-URL: Documentation, https://docs.tryheimdall.com
|
|
7
|
+
Project-URL: Repository, https://github.com/hmdl-inc/heimdall-python
|
|
8
|
+
Project-URL: Issues, https://github.com/hmdl-inc/heimdall-python/issues
|
|
9
|
+
Author-email: Heimdall Team <founder@tryheimdall.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai,llm,mcp,model-context-protocol,monitoring,observability,opentelemetry,tracing
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Monitoring
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: opentelemetry-api>=1.20.0
|
|
26
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.20.0
|
|
27
|
+
Requires-Dist: opentelemetry-sdk>=1.20.0
|
|
28
|
+
Requires-Dist: opentelemetry-semantic-conventions>=0.41b0
|
|
29
|
+
Requires-Dist: typing-extensions>=4.0.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
37
|
+
Description-Content-Type: text/markdown
|
|
38
|
+
|
|
39
|
+
# hmdl - Heimdall Observability SDK for Python
|
|
40
|
+
|
|
41
|
+
[](https://badge.fury.io/py/hmdl)
|
|
42
|
+
[](https://www.python.org/downloads/)
|
|
43
|
+
[](https://opensource.org/licenses/MIT)
|
|
44
|
+
|
|
45
|
+
Observability SDK for MCP (Model Context Protocol) servers, built on OpenTelemetry.
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install hmdl
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. Create Organization and Project in Heimdall
|
|
56
|
+
|
|
57
|
+
Before using the SDK, you need to set up your organization and project in the Heimdall dashboard:
|
|
58
|
+
|
|
59
|
+
1. Start the Heimdall backend and frontend (see [Heimdall Documentation](https://docs.tryheimdall.com))
|
|
60
|
+
2. Navigate to http://localhost:5173
|
|
61
|
+
3. **Create an account** with your email and password
|
|
62
|
+
4. **Create an Organization** - this groups your projects together
|
|
63
|
+
5. **Create a Project** - each project has a unique ID for trace collection
|
|
64
|
+
6. Go to **Settings** to find your **Organization ID** and **Project ID**
|
|
65
|
+
|
|
66
|
+
### 2. Set up environment variables
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# Required for local development
|
|
70
|
+
export HEIMDALL_ENDPOINT="http://localhost:4318" # Your Heimdall backend
|
|
71
|
+
export HEIMDALL_ORG_ID="your-org-id" # From Heimdall Settings page
|
|
72
|
+
export HEIMDALL_PROJECT_ID="your-project-id" # From Heimdall Settings page
|
|
73
|
+
export HEIMDALL_ENABLED="true"
|
|
74
|
+
|
|
75
|
+
# Optional
|
|
76
|
+
export HEIMDALL_SERVICE_NAME="my-mcp-server"
|
|
77
|
+
export HEIMDALL_ENVIRONMENT="development"
|
|
78
|
+
|
|
79
|
+
# For production (with API key)
|
|
80
|
+
export HEIMDALL_API_KEY="your-api-key"
|
|
81
|
+
export HEIMDALL_ENDPOINT="https://api.heimdall.dev"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 3. Initialize the client
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from hmdl import HeimdallClient
|
|
88
|
+
|
|
89
|
+
# Initialize (uses environment variables by default)
|
|
90
|
+
client = HeimdallClient()
|
|
91
|
+
|
|
92
|
+
# Or with explicit configuration
|
|
93
|
+
client = HeimdallClient(
|
|
94
|
+
endpoint="http://localhost:4318",
|
|
95
|
+
org_id="your-org-id", # From Settings page
|
|
96
|
+
project_id="your-project-id", # From Settings page
|
|
97
|
+
service_name="my-mcp-server",
|
|
98
|
+
environment="development"
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 4. Instrument your MCP tool functions
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from hmdl import trace_mcp_tool
|
|
106
|
+
|
|
107
|
+
@trace_mcp_tool()
|
|
108
|
+
def search_documents(query: str, limit: int = 10) -> list:
|
|
109
|
+
"""Search for documents matching the query."""
|
|
110
|
+
# Your implementation here
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
@trace_mcp_tool("custom-tool-name")
|
|
114
|
+
def another_tool(data: dict) -> dict:
|
|
115
|
+
"""Another MCP tool with custom name."""
|
|
116
|
+
return {"processed": True, **data}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 5. Async support
|
|
120
|
+
|
|
121
|
+
The decorator works with async functions:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
@trace_mcp_tool()
|
|
125
|
+
async def async_search(query: str) -> list:
|
|
126
|
+
results = await database.search(query)
|
|
127
|
+
return results
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Configuration
|
|
131
|
+
|
|
132
|
+
| Environment Variable | Description | Default |
|
|
133
|
+
|---------------------|-------------|---------|
|
|
134
|
+
| `HEIMDALL_ENDPOINT` | Heimdall backend URL | `http://localhost:4318` |
|
|
135
|
+
| `HEIMDALL_ORG_ID` | Organization ID (from Settings page) | `default` |
|
|
136
|
+
| `HEIMDALL_PROJECT_ID` | Project ID (from Settings page) | `default` |
|
|
137
|
+
| `HEIMDALL_ENABLED` | Enable/disable tracing | `true` |
|
|
138
|
+
| `HEIMDALL_SERVICE_NAME` | Service name for traces | `mcp-server` |
|
|
139
|
+
| `HEIMDALL_ENVIRONMENT` | Deployment environment | `development` |
|
|
140
|
+
| `HEIMDALL_API_KEY` | API key (optional for local dev) | - |
|
|
141
|
+
| `HEIMDALL_DEBUG` | Enable debug logging | `false` |
|
|
142
|
+
| `HEIMDALL_BATCH_SIZE` | Spans per batch | `100` |
|
|
143
|
+
| `HEIMDALL_FLUSH_INTERVAL_MS` | Flush interval (ms) | `5000` |
|
|
144
|
+
|
|
145
|
+
### Local Development
|
|
146
|
+
|
|
147
|
+
For local development, you don't need an API key. Just set:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
export HEIMDALL_ENDPOINT="http://localhost:4318"
|
|
151
|
+
export HEIMDALL_ORG_ID="your-org-id" # Copy from Settings page
|
|
152
|
+
export HEIMDALL_PROJECT_ID="your-project-id" # Copy from Settings page
|
|
153
|
+
export HEIMDALL_ENABLED="true"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Advanced Usage
|
|
157
|
+
|
|
158
|
+
### Custom span names
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
@trace_mcp_tool("custom-tool-name")
|
|
162
|
+
def my_tool():
|
|
163
|
+
pass
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Manual spans
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from hmdl import HeimdallClient
|
|
170
|
+
|
|
171
|
+
client = HeimdallClient()
|
|
172
|
+
|
|
173
|
+
with client.start_span("my-operation") as span:
|
|
174
|
+
span.set_attribute("custom.attribute", "value")
|
|
175
|
+
# Your code here
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Flush on shutdown
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
import atexit
|
|
182
|
+
from hmdl import HeimdallClient
|
|
183
|
+
|
|
184
|
+
client = HeimdallClient()
|
|
185
|
+
|
|
186
|
+
# Ensure spans are flushed on exit
|
|
187
|
+
atexit.register(client.flush)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## What gets tracked?
|
|
191
|
+
|
|
192
|
+
For each MCP function call, Heimdall tracks:
|
|
193
|
+
|
|
194
|
+
- **Input parameters**: Function arguments (serialized to JSON)
|
|
195
|
+
- **Output/response**: Return value (serialized to JSON)
|
|
196
|
+
- **Status**: Success or error
|
|
197
|
+
- **Latency**: Execution time in milliseconds
|
|
198
|
+
- **Errors**: Exception type, message, and stack trace
|
|
199
|
+
- **Metadata**: Service name, environment, timestamps
|
|
200
|
+
|
|
201
|
+
## OpenTelemetry Integration
|
|
202
|
+
|
|
203
|
+
This SDK is built on OpenTelemetry, making it compatible with the broader observability ecosystem. You can:
|
|
204
|
+
|
|
205
|
+
- Use existing OTel instrumentations alongside Heimdall
|
|
206
|
+
- Export to multiple backends simultaneously
|
|
207
|
+
- Leverage OTel's context propagation for distributed tracing
|
|
208
|
+
|
|
209
|
+
## License
|
|
210
|
+
|
|
211
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
212
|
+
|
hmdl-0.0.1/README.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# hmdl - Heimdall Observability SDK for Python
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/py/hmdl)
|
|
4
|
+
[](https://www.python.org/downloads/)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Observability SDK for MCP (Model Context Protocol) servers, built on OpenTelemetry.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install hmdl
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
### 1. Create Organization and Project in Heimdall
|
|
18
|
+
|
|
19
|
+
Before using the SDK, you need to set up your organization and project in the Heimdall dashboard:
|
|
20
|
+
|
|
21
|
+
1. Start the Heimdall backend and frontend (see [Heimdall Documentation](https://docs.tryheimdall.com))
|
|
22
|
+
2. Navigate to http://localhost:5173
|
|
23
|
+
3. **Create an account** with your email and password
|
|
24
|
+
4. **Create an Organization** - this groups your projects together
|
|
25
|
+
5. **Create a Project** - each project has a unique ID for trace collection
|
|
26
|
+
6. Go to **Settings** to find your **Organization ID** and **Project ID**
|
|
27
|
+
|
|
28
|
+
### 2. Set up environment variables
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Required for local development
|
|
32
|
+
export HEIMDALL_ENDPOINT="http://localhost:4318" # Your Heimdall backend
|
|
33
|
+
export HEIMDALL_ORG_ID="your-org-id" # From Heimdall Settings page
|
|
34
|
+
export HEIMDALL_PROJECT_ID="your-project-id" # From Heimdall Settings page
|
|
35
|
+
export HEIMDALL_ENABLED="true"
|
|
36
|
+
|
|
37
|
+
# Optional
|
|
38
|
+
export HEIMDALL_SERVICE_NAME="my-mcp-server"
|
|
39
|
+
export HEIMDALL_ENVIRONMENT="development"
|
|
40
|
+
|
|
41
|
+
# For production (with API key)
|
|
42
|
+
export HEIMDALL_API_KEY="your-api-key"
|
|
43
|
+
export HEIMDALL_ENDPOINT="https://api.heimdall.dev"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 3. Initialize the client
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from hmdl import HeimdallClient
|
|
50
|
+
|
|
51
|
+
# Initialize (uses environment variables by default)
|
|
52
|
+
client = HeimdallClient()
|
|
53
|
+
|
|
54
|
+
# Or with explicit configuration
|
|
55
|
+
client = HeimdallClient(
|
|
56
|
+
endpoint="http://localhost:4318",
|
|
57
|
+
org_id="your-org-id", # From Settings page
|
|
58
|
+
project_id="your-project-id", # From Settings page
|
|
59
|
+
service_name="my-mcp-server",
|
|
60
|
+
environment="development"
|
|
61
|
+
)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 4. Instrument your MCP tool functions
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from hmdl import trace_mcp_tool
|
|
68
|
+
|
|
69
|
+
@trace_mcp_tool()
|
|
70
|
+
def search_documents(query: str, limit: int = 10) -> list:
|
|
71
|
+
"""Search for documents matching the query."""
|
|
72
|
+
# Your implementation here
|
|
73
|
+
return results
|
|
74
|
+
|
|
75
|
+
@trace_mcp_tool("custom-tool-name")
|
|
76
|
+
def another_tool(data: dict) -> dict:
|
|
77
|
+
"""Another MCP tool with custom name."""
|
|
78
|
+
return {"processed": True, **data}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 5. Async support
|
|
82
|
+
|
|
83
|
+
The decorator works with async functions:
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
@trace_mcp_tool()
|
|
87
|
+
async def async_search(query: str) -> list:
|
|
88
|
+
results = await database.search(query)
|
|
89
|
+
return results
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
| Environment Variable | Description | Default |
|
|
95
|
+
|---------------------|-------------|---------|
|
|
96
|
+
| `HEIMDALL_ENDPOINT` | Heimdall backend URL | `http://localhost:4318` |
|
|
97
|
+
| `HEIMDALL_ORG_ID` | Organization ID (from Settings page) | `default` |
|
|
98
|
+
| `HEIMDALL_PROJECT_ID` | Project ID (from Settings page) | `default` |
|
|
99
|
+
| `HEIMDALL_ENABLED` | Enable/disable tracing | `true` |
|
|
100
|
+
| `HEIMDALL_SERVICE_NAME` | Service name for traces | `mcp-server` |
|
|
101
|
+
| `HEIMDALL_ENVIRONMENT` | Deployment environment | `development` |
|
|
102
|
+
| `HEIMDALL_API_KEY` | API key (optional for local dev) | - |
|
|
103
|
+
| `HEIMDALL_DEBUG` | Enable debug logging | `false` |
|
|
104
|
+
| `HEIMDALL_BATCH_SIZE` | Spans per batch | `100` |
|
|
105
|
+
| `HEIMDALL_FLUSH_INTERVAL_MS` | Flush interval (ms) | `5000` |
|
|
106
|
+
|
|
107
|
+
### Local Development
|
|
108
|
+
|
|
109
|
+
For local development, you don't need an API key. Just set:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
export HEIMDALL_ENDPOINT="http://localhost:4318"
|
|
113
|
+
export HEIMDALL_ORG_ID="your-org-id" # Copy from Settings page
|
|
114
|
+
export HEIMDALL_PROJECT_ID="your-project-id" # Copy from Settings page
|
|
115
|
+
export HEIMDALL_ENABLED="true"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Advanced Usage
|
|
119
|
+
|
|
120
|
+
### Custom span names
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
@trace_mcp_tool("custom-tool-name")
|
|
124
|
+
def my_tool():
|
|
125
|
+
pass
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Manual spans
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
from hmdl import HeimdallClient
|
|
132
|
+
|
|
133
|
+
client = HeimdallClient()
|
|
134
|
+
|
|
135
|
+
with client.start_span("my-operation") as span:
|
|
136
|
+
span.set_attribute("custom.attribute", "value")
|
|
137
|
+
# Your code here
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Flush on shutdown
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
import atexit
|
|
144
|
+
from hmdl import HeimdallClient
|
|
145
|
+
|
|
146
|
+
client = HeimdallClient()
|
|
147
|
+
|
|
148
|
+
# Ensure spans are flushed on exit
|
|
149
|
+
atexit.register(client.flush)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## What gets tracked?
|
|
153
|
+
|
|
154
|
+
For each MCP function call, Heimdall tracks:
|
|
155
|
+
|
|
156
|
+
- **Input parameters**: Function arguments (serialized to JSON)
|
|
157
|
+
- **Output/response**: Return value (serialized to JSON)
|
|
158
|
+
- **Status**: Success or error
|
|
159
|
+
- **Latency**: Execution time in milliseconds
|
|
160
|
+
- **Errors**: Exception type, message, and stack trace
|
|
161
|
+
- **Metadata**: Service name, environment, timestamps
|
|
162
|
+
|
|
163
|
+
## OpenTelemetry Integration
|
|
164
|
+
|
|
165
|
+
This SDK is built on OpenTelemetry, making it compatible with the broader observability ecosystem. You can:
|
|
166
|
+
|
|
167
|
+
- Use existing OTel instrumentations alongside Heimdall
|
|
168
|
+
- Export to multiple backends simultaneously
|
|
169
|
+
- Leverage OTel's context propagation for distributed tracing
|
|
170
|
+
|
|
171
|
+
## License
|
|
172
|
+
|
|
173
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
|
174
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hmdl"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Observability SDK for MCP (Model Context Protocol) servers - Heimdall Platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Heimdall Team", email = "founder@tryheimdall.com" }
|
|
13
|
+
]
|
|
14
|
+
keywords = [
|
|
15
|
+
"observability",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"opentelemetry",
|
|
19
|
+
"tracing",
|
|
20
|
+
"monitoring",
|
|
21
|
+
"llm",
|
|
22
|
+
"ai"
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 3 - Alpha",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"License :: OSI Approved :: MIT License",
|
|
28
|
+
"Operating System :: OS Independent",
|
|
29
|
+
"Programming Language :: Python :: 3",
|
|
30
|
+
"Programming Language :: Python :: 3.9",
|
|
31
|
+
"Programming Language :: Python :: 3.10",
|
|
32
|
+
"Programming Language :: Python :: 3.11",
|
|
33
|
+
"Programming Language :: Python :: 3.12",
|
|
34
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
35
|
+
"Topic :: System :: Monitoring",
|
|
36
|
+
]
|
|
37
|
+
requires-python = ">=3.9"
|
|
38
|
+
dependencies = [
|
|
39
|
+
"opentelemetry-api>=1.20.0",
|
|
40
|
+
"opentelemetry-sdk>=1.20.0",
|
|
41
|
+
"opentelemetry-exporter-otlp>=1.20.0",
|
|
42
|
+
"opentelemetry-semantic-conventions>=0.41b0",
|
|
43
|
+
"typing-extensions>=4.0.0",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.optional-dependencies]
|
|
47
|
+
dev = [
|
|
48
|
+
"pytest>=7.0.0",
|
|
49
|
+
"pytest-asyncio>=0.21.0",
|
|
50
|
+
"pytest-cov>=4.0.0",
|
|
51
|
+
"mypy>=1.0.0",
|
|
52
|
+
"ruff>=0.1.0",
|
|
53
|
+
"black>=23.0.0",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[project.urls]
|
|
57
|
+
Homepage = "https://tryheimdall.com"
|
|
58
|
+
Documentation = "https://docs.tryheimdall.com"
|
|
59
|
+
Repository = "https://github.com/hmdl-inc/heimdall-python"
|
|
60
|
+
Issues = "https://github.com/hmdl-inc/heimdall-python/issues"
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build.targets.wheel]
|
|
63
|
+
packages = ["src/hmdl"]
|
|
64
|
+
|
|
65
|
+
[tool.hatch.build.targets.sdist]
|
|
66
|
+
include = [
|
|
67
|
+
"/src",
|
|
68
|
+
"/README.md",
|
|
69
|
+
"/LICENSE",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 100
|
|
74
|
+
target-version = "py39"
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = ["E", "F", "I", "W"]
|
|
78
|
+
|
|
79
|
+
[tool.mypy]
|
|
80
|
+
python_version = "3.9"
|
|
81
|
+
strict = true
|
|
82
|
+
warn_return_any = true
|
|
83
|
+
warn_unused_configs = true
|
|
84
|
+
|
|
85
|
+
[tool.pytest.ini_options]
|
|
86
|
+
testpaths = ["tests"]
|
|
87
|
+
asyncio_mode = "auto"
|
|
88
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Heimdall Observability SDK for MCP Servers.
|
|
3
|
+
|
|
4
|
+
A Python SDK for instrumenting MCP (Model Context Protocol) servers with
|
|
5
|
+
OpenTelemetry-based observability tracking.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from hmdl.client import HeimdallClient
|
|
9
|
+
from hmdl.decorators import trace_mcp_tool
|
|
10
|
+
from hmdl.config import HeimdallConfig
|
|
11
|
+
from hmdl.types import SpanKind, SpanStatus
|
|
12
|
+
|
|
13
|
+
__version__ = "0.0.1"
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
# Client
|
|
17
|
+
"HeimdallClient",
|
|
18
|
+
# Decorators
|
|
19
|
+
"trace_mcp_tool",
|
|
20
|
+
# Configuration
|
|
21
|
+
"HeimdallConfig",
|
|
22
|
+
# Types
|
|
23
|
+
"SpanKind",
|
|
24
|
+
"SpanStatus",
|
|
25
|
+
# Version
|
|
26
|
+
"__version__",
|
|
27
|
+
]
|
|
28
|
+
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Heimdall client for OpenTelemetry-based observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
from opentelemetry import trace
|
|
10
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
11
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
12
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
|
|
13
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
|
14
|
+
|
|
15
|
+
from hmdl.config import HeimdallConfig
|
|
16
|
+
from hmdl.types import HeimdallAttributes
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HeimdallClient:
|
|
22
|
+
"""Client for sending observability data to Heimdall platform.
|
|
23
|
+
|
|
24
|
+
This client sets up OpenTelemetry tracing and provides methods for
|
|
25
|
+
creating spans and recording MCP operations.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
>>> from hmdl import HeimdallClient
|
|
29
|
+
>>> client = HeimdallClient(api_key="your-api-key")
|
|
30
|
+
>>> with client.start_span("my-operation") as span:
|
|
31
|
+
... # Your code here
|
|
32
|
+
... span.set_attribute("custom.attribute", "value")
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_instance: Optional["HeimdallClient"] = None
|
|
36
|
+
_initialized: bool = False
|
|
37
|
+
|
|
38
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> "HeimdallClient":
|
|
39
|
+
"""Singleton pattern to ensure only one client instance."""
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
cls._instance = super().__new__(cls)
|
|
42
|
+
return cls._instance
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
config: Optional[HeimdallConfig] = None,
|
|
47
|
+
api_key: Optional[str] = None,
|
|
48
|
+
endpoint: Optional[str] = None,
|
|
49
|
+
service_name: Optional[str] = None,
|
|
50
|
+
environment: Optional[str] = None,
|
|
51
|
+
org_id: Optional[str] = None,
|
|
52
|
+
project_id: Optional[str] = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Initialize the Heimdall client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config: Full configuration object. If provided, other args are ignored.
|
|
58
|
+
api_key: API key for Heimdall platform.
|
|
59
|
+
endpoint: Heimdall platform endpoint URL.
|
|
60
|
+
service_name: Name of the service being instrumented.
|
|
61
|
+
environment: Deployment environment.
|
|
62
|
+
org_id: Organization ID from Heimdall dashboard.
|
|
63
|
+
project_id: Project ID from Heimdall dashboard.
|
|
64
|
+
"""
|
|
65
|
+
if self._initialized:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Build config from arguments or use provided config
|
|
69
|
+
if config is not None:
|
|
70
|
+
self.config = config
|
|
71
|
+
else:
|
|
72
|
+
self.config = HeimdallConfig(
|
|
73
|
+
api_key=api_key or HeimdallConfig().api_key,
|
|
74
|
+
endpoint=endpoint or HeimdallConfig().endpoint,
|
|
75
|
+
service_name=service_name or HeimdallConfig().service_name,
|
|
76
|
+
environment=environment or HeimdallConfig().environment,
|
|
77
|
+
org_id=org_id or HeimdallConfig().org_id,
|
|
78
|
+
project_id=project_id or HeimdallConfig().project_id,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
self._tracer: Optional[trace.Tracer] = None
|
|
82
|
+
self._provider: Optional[TracerProvider] = None
|
|
83
|
+
|
|
84
|
+
if self.config.enabled:
|
|
85
|
+
self._setup_tracing()
|
|
86
|
+
|
|
87
|
+
self._initialized = True
|
|
88
|
+
|
|
89
|
+
# Register cleanup on exit
|
|
90
|
+
atexit.register(self.shutdown)
|
|
91
|
+
|
|
92
|
+
def _setup_tracing(self) -> None:
|
|
93
|
+
"""Set up OpenTelemetry tracing."""
|
|
94
|
+
if self.config.debug:
|
|
95
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
96
|
+
logger.setLevel(logging.DEBUG)
|
|
97
|
+
|
|
98
|
+
# Create resource with service information
|
|
99
|
+
resource = Resource.create({
|
|
100
|
+
SERVICE_NAME: self.config.service_name,
|
|
101
|
+
HeimdallAttributes.HEIMDALL_ENVIRONMENT: self.config.environment,
|
|
102
|
+
HeimdallAttributes.HEIMDALL_ORG_ID: self.config.org_id,
|
|
103
|
+
HeimdallAttributes.HEIMDALL_PROJECT_ID: self.config.project_id,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
# Create tracer provider
|
|
107
|
+
self._provider = TracerProvider(resource=resource)
|
|
108
|
+
|
|
109
|
+
# Set up OTLP HTTP exporter
|
|
110
|
+
otlp_endpoint = f"{self.config.endpoint}/v1/traces"
|
|
111
|
+
|
|
112
|
+
# Only add auth header if API key is provided
|
|
113
|
+
headers = {}
|
|
114
|
+
if self.config.api_key:
|
|
115
|
+
headers["Authorization"] = f"Bearer {self.config.api_key}"
|
|
116
|
+
|
|
117
|
+
exporter = OTLPSpanExporter(
|
|
118
|
+
endpoint=otlp_endpoint,
|
|
119
|
+
headers=headers if headers else None,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Add batch processor for efficient span export
|
|
123
|
+
processor = BatchSpanProcessor(
|
|
124
|
+
exporter,
|
|
125
|
+
max_queue_size=self.config.max_queue_size,
|
|
126
|
+
max_export_batch_size=self.config.batch_size,
|
|
127
|
+
schedule_delay_millis=self.config.flush_interval_ms,
|
|
128
|
+
)
|
|
129
|
+
self._provider.add_span_processor(processor)
|
|
130
|
+
|
|
131
|
+
# Set as global tracer provider
|
|
132
|
+
trace.set_tracer_provider(self._provider)
|
|
133
|
+
|
|
134
|
+
# Get tracer
|
|
135
|
+
self._tracer = trace.get_tracer("hmdl", "0.1.0")
|
|
136
|
+
|
|
137
|
+
logger.debug(f"Heimdall tracing initialized for service: {self.config.service_name}")
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def tracer(self) -> trace.Tracer:
|
|
141
|
+
"""Get the OpenTelemetry tracer."""
|
|
142
|
+
if self._tracer is None:
|
|
143
|
+
# Return a no-op tracer if not initialized
|
|
144
|
+
return trace.get_tracer("hmdl-noop")
|
|
145
|
+
return self._tracer
|
|
146
|
+
|
|
147
|
+
def start_span(
|
|
148
|
+
self,
|
|
149
|
+
name: str,
|
|
150
|
+
kind: trace.SpanKind = trace.SpanKind.INTERNAL,
|
|
151
|
+
attributes: Optional[Dict[str, Any]] = None,
|
|
152
|
+
) -> trace.Span:
|
|
153
|
+
"""Start a new span.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
name: Name of the span.
|
|
157
|
+
kind: Kind of span (INTERNAL, CLIENT, SERVER, etc.).
|
|
158
|
+
attributes: Initial attributes for the span.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
The created span as a context manager.
|
|
162
|
+
"""
|
|
163
|
+
return self.tracer.start_as_current_span(
|
|
164
|
+
name=name,
|
|
165
|
+
kind=kind,
|
|
166
|
+
attributes=attributes,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def get_current_span(self) -> trace.Span:
|
|
170
|
+
"""Get the current active span."""
|
|
171
|
+
return trace.get_current_span()
|
|
172
|
+
|
|
173
|
+
def flush(self) -> None:
|
|
174
|
+
"""Flush all pending spans."""
|
|
175
|
+
if self._provider is not None:
|
|
176
|
+
self._provider.force_flush()
|
|
177
|
+
|
|
178
|
+
def shutdown(self) -> None:
|
|
179
|
+
"""Shutdown the client and flush remaining spans."""
|
|
180
|
+
if self._provider is not None:
|
|
181
|
+
self._provider.shutdown()
|
|
182
|
+
logger.debug("Heimdall client shutdown complete")
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def get_instance(cls) -> Optional["HeimdallClient"]:
|
|
186
|
+
"""Get the singleton client instance."""
|
|
187
|
+
return cls._instance
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def reset(cls) -> None:
|
|
191
|
+
"""Reset the singleton instance (mainly for testing)."""
|
|
192
|
+
if cls._instance is not None:
|
|
193
|
+
cls._instance.shutdown()
|
|
194
|
+
cls._instance = None
|
|
195
|
+
cls._initialized = False
|
|
196
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Configuration for Heimdall SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Optional, Dict, Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class HeimdallConfig:
|
|
12
|
+
"""Configuration for the Heimdall observability client.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
api_key: API key for authenticating with Heimdall platform.
|
|
16
|
+
endpoint: The Heimdall platform endpoint URL.
|
|
17
|
+
service_name: Name of the service being instrumented.
|
|
18
|
+
environment: Deployment environment (e.g., 'production', 'staging').
|
|
19
|
+
org_id: Organization ID from Heimdall dashboard.
|
|
20
|
+
project_id: Project ID to associate traces with in Heimdall.
|
|
21
|
+
enabled: Whether tracing is enabled.
|
|
22
|
+
debug: Enable debug logging.
|
|
23
|
+
batch_size: Number of spans to batch before sending.
|
|
24
|
+
flush_interval_ms: Interval in milliseconds to flush spans.
|
|
25
|
+
max_queue_size: Maximum number of spans to queue.
|
|
26
|
+
metadata: Additional metadata to attach to all spans.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
api_key: Optional[str] = field(
|
|
30
|
+
default_factory=lambda: os.environ.get("HEIMDALL_API_KEY")
|
|
31
|
+
)
|
|
32
|
+
endpoint: str = field(
|
|
33
|
+
default_factory=lambda: os.environ.get(
|
|
34
|
+
"HEIMDALL_ENDPOINT", "https://api.heimdall.dev"
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
service_name: str = field(
|
|
38
|
+
default_factory=lambda: os.environ.get("HEIMDALL_SERVICE_NAME", "mcp-server")
|
|
39
|
+
)
|
|
40
|
+
environment: str = field(
|
|
41
|
+
default_factory=lambda: os.environ.get("HEIMDALL_ENVIRONMENT", "development")
|
|
42
|
+
)
|
|
43
|
+
org_id: str = field(
|
|
44
|
+
default_factory=lambda: os.environ.get("HEIMDALL_ORG_ID", "default")
|
|
45
|
+
)
|
|
46
|
+
project_id: str = field(
|
|
47
|
+
default_factory=lambda: os.environ.get("HEIMDALL_PROJECT_ID", "default")
|
|
48
|
+
)
|
|
49
|
+
enabled: bool = field(
|
|
50
|
+
default_factory=lambda: os.environ.get("HEIMDALL_ENABLED", "true").lower() == "true"
|
|
51
|
+
)
|
|
52
|
+
debug: bool = field(
|
|
53
|
+
default_factory=lambda: os.environ.get("HEIMDALL_DEBUG", "false").lower() == "true"
|
|
54
|
+
)
|
|
55
|
+
batch_size: int = field(
|
|
56
|
+
default_factory=lambda: int(os.environ.get("HEIMDALL_BATCH_SIZE", "100"))
|
|
57
|
+
)
|
|
58
|
+
flush_interval_ms: int = field(
|
|
59
|
+
default_factory=lambda: int(os.environ.get("HEIMDALL_FLUSH_INTERVAL_MS", "5000"))
|
|
60
|
+
)
|
|
61
|
+
max_queue_size: int = field(
|
|
62
|
+
default_factory=lambda: int(os.environ.get("HEIMDALL_MAX_QUEUE_SIZE", "1000"))
|
|
63
|
+
)
|
|
64
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
def validate(self) -> None:
|
|
67
|
+
"""Validate the configuration."""
|
|
68
|
+
# API key is optional for local development
|
|
69
|
+
if self.batch_size < 1:
|
|
70
|
+
raise ValueError("batch_size must be at least 1")
|
|
71
|
+
if self.flush_interval_ms < 100:
|
|
72
|
+
raise ValueError("flush_interval_ms must be at least 100")
|
|
73
|
+
if self.max_queue_size < self.batch_size:
|
|
74
|
+
raise ValueError("max_queue_size must be at least batch_size")
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_env(cls) -> "HeimdallConfig":
|
|
78
|
+
"""Create configuration from environment variables."""
|
|
79
|
+
return cls()
|
|
80
|
+
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Decorators for instrumenting MCP functions with Heimdall observability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Callable, Optional, TypeVar, Union, overload
|
|
10
|
+
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.trace import Status, StatusCode
|
|
13
|
+
|
|
14
|
+
from hmdl.types import HeimdallAttributes, SpanKind, SpanStatus
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _serialize_value(value: Any) -> str:
|
|
20
|
+
"""Safely serialize a value to string for span attributes."""
|
|
21
|
+
try:
|
|
22
|
+
return json.dumps(value, default=str)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return str(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_client() -> Any:
|
|
28
|
+
"""Get the Heimdall client instance."""
|
|
29
|
+
from hmdl.client import HeimdallClient
|
|
30
|
+
return HeimdallClient.get_instance()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _create_span_decorator(
|
|
34
|
+
span_kind: SpanKind,
|
|
35
|
+
name_attr: str,
|
|
36
|
+
args_attr: str,
|
|
37
|
+
result_attr: str,
|
|
38
|
+
) -> Callable[[Optional[str]], Callable[[F], F]]:
|
|
39
|
+
"""Factory for creating MCP-specific decorators."""
|
|
40
|
+
|
|
41
|
+
def decorator(name: Optional[str] = None) -> Callable[[F], F]:
|
|
42
|
+
def wrapper(func: F) -> F:
|
|
43
|
+
span_name = name or func.__name__
|
|
44
|
+
is_async = inspect.iscoroutinefunction(func)
|
|
45
|
+
|
|
46
|
+
if is_async:
|
|
47
|
+
@functools.wraps(func)
|
|
48
|
+
async def async_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
49
|
+
client = _get_client()
|
|
50
|
+
if client is None:
|
|
51
|
+
return await func(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
tracer = client.tracer
|
|
54
|
+
with tracer.start_as_current_span(
|
|
55
|
+
name=span_name,
|
|
56
|
+
kind=trace.SpanKind.SERVER,
|
|
57
|
+
) as span:
|
|
58
|
+
start_time = time.perf_counter()
|
|
59
|
+
|
|
60
|
+
# Set input attributes
|
|
61
|
+
span.set_attribute(name_attr, span_name)
|
|
62
|
+
span.set_attribute("heimdall.span_kind", span_kind.value)
|
|
63
|
+
|
|
64
|
+
# Capture arguments
|
|
65
|
+
try:
|
|
66
|
+
all_args = _capture_arguments(func, args, kwargs)
|
|
67
|
+
span.set_attribute(args_attr, _serialize_value(all_args))
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = await func(*args, **kwargs)
|
|
73
|
+
|
|
74
|
+
# Set output attributes
|
|
75
|
+
span.set_attribute(result_attr, _serialize_value(result))
|
|
76
|
+
span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
|
|
77
|
+
span.set_status(Status(StatusCode.OK))
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
except Exception as e:
|
|
81
|
+
_record_error(span, e)
|
|
82
|
+
raise
|
|
83
|
+
finally:
|
|
84
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
85
|
+
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
86
|
+
|
|
87
|
+
return async_wrapped # type: ignore
|
|
88
|
+
else:
|
|
89
|
+
@functools.wraps(func)
|
|
90
|
+
def sync_wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
91
|
+
client = _get_client()
|
|
92
|
+
if client is None:
|
|
93
|
+
return func(*args, **kwargs)
|
|
94
|
+
|
|
95
|
+
tracer = client.tracer
|
|
96
|
+
with tracer.start_as_current_span(
|
|
97
|
+
name=span_name,
|
|
98
|
+
kind=trace.SpanKind.SERVER,
|
|
99
|
+
) as span:
|
|
100
|
+
start_time = time.perf_counter()
|
|
101
|
+
|
|
102
|
+
# Set input attributes
|
|
103
|
+
span.set_attribute(name_attr, span_name)
|
|
104
|
+
span.set_attribute("heimdall.span_kind", span_kind.value)
|
|
105
|
+
|
|
106
|
+
# Capture arguments
|
|
107
|
+
try:
|
|
108
|
+
all_args = _capture_arguments(func, args, kwargs)
|
|
109
|
+
span.set_attribute(args_attr, _serialize_value(all_args))
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
result = func(*args, **kwargs)
|
|
115
|
+
|
|
116
|
+
# Set output attributes
|
|
117
|
+
span.set_attribute(result_attr, _serialize_value(result))
|
|
118
|
+
span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.OK.value)
|
|
119
|
+
span.set_status(Status(StatusCode.OK))
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
except Exception as e:
|
|
123
|
+
_record_error(span, e)
|
|
124
|
+
raise
|
|
125
|
+
finally:
|
|
126
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
127
|
+
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
128
|
+
|
|
129
|
+
return sync_wrapped # type: ignore
|
|
130
|
+
|
|
131
|
+
return wrapper
|
|
132
|
+
|
|
133
|
+
return decorator
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _capture_arguments(func: Callable[..., Any], args: tuple, kwargs: dict) -> dict:
|
|
137
|
+
"""Capture function arguments as a dictionary."""
|
|
138
|
+
sig = inspect.signature(func)
|
|
139
|
+
bound = sig.bind_partial(*args, **kwargs)
|
|
140
|
+
bound.apply_defaults()
|
|
141
|
+
return dict(bound.arguments)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _record_error(span: trace.Span, error: Exception) -> None:
|
|
145
|
+
"""Record an error on a span."""
|
|
146
|
+
span.set_attribute(HeimdallAttributes.STATUS, SpanStatus.ERROR.value)
|
|
147
|
+
span.set_attribute(HeimdallAttributes.ERROR_MESSAGE, str(error))
|
|
148
|
+
span.set_attribute(HeimdallAttributes.ERROR_TYPE, type(error).__name__)
|
|
149
|
+
span.set_status(Status(StatusCode.ERROR, str(error)))
|
|
150
|
+
span.record_exception(error)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# Create MCP-specific decorators
|
|
154
|
+
trace_mcp_tool = _create_span_decorator(
|
|
155
|
+
span_kind=SpanKind.MCP_TOOL,
|
|
156
|
+
name_attr=HeimdallAttributes.MCP_TOOL_NAME,
|
|
157
|
+
args_attr=HeimdallAttributes.MCP_TOOL_ARGUMENTS,
|
|
158
|
+
result_attr=HeimdallAttributes.MCP_TOOL_RESULT,
|
|
159
|
+
)
|
|
160
|
+
trace_mcp_tool.__doc__ = """
|
|
161
|
+
Decorator to trace MCP tool calls.
|
|
162
|
+
|
|
163
|
+
Example:
|
|
164
|
+
>>> @trace_mcp_tool()
|
|
165
|
+
... def my_tool(arg1: str, arg2: int) -> str:
|
|
166
|
+
... return f"Result: {arg1}, {arg2}"
|
|
167
|
+
|
|
168
|
+
>>> @trace_mcp_tool("custom-tool-name")
|
|
169
|
+
... async def async_tool(data: dict) -> dict:
|
|
170
|
+
... return {"processed": data}
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
trace_mcp_resource = _create_span_decorator(
|
|
174
|
+
span_kind=SpanKind.MCP_RESOURCE,
|
|
175
|
+
name_attr=HeimdallAttributes.MCP_RESOURCE_URI,
|
|
176
|
+
args_attr="mcp.resource.arguments",
|
|
177
|
+
result_attr="mcp.resource.result",
|
|
178
|
+
)
|
|
179
|
+
trace_mcp_resource.__doc__ = """
|
|
180
|
+
Decorator to trace MCP resource access.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
>>> @trace_mcp_resource()
|
|
184
|
+
... def read_file(uri: str) -> str:
|
|
185
|
+
... return open(uri).read()
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
trace_mcp_prompt = _create_span_decorator(
|
|
189
|
+
span_kind=SpanKind.MCP_PROMPT,
|
|
190
|
+
name_attr=HeimdallAttributes.MCP_PROMPT_NAME,
|
|
191
|
+
args_attr=HeimdallAttributes.MCP_PROMPT_ARGUMENTS,
|
|
192
|
+
result_attr=HeimdallAttributes.MCP_PROMPT_MESSAGES,
|
|
193
|
+
)
|
|
194
|
+
trace_mcp_prompt.__doc__ = """
|
|
195
|
+
Decorator to trace MCP prompt calls.
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
>>> @trace_mcp_prompt()
|
|
199
|
+
... def generate_prompt(context: str) -> list:
|
|
200
|
+
... return [{"role": "user", "content": context}]
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@overload
|
|
205
|
+
def observe(func: F) -> F: ...
|
|
206
|
+
|
|
207
|
+
@overload
|
|
208
|
+
def observe(
|
|
209
|
+
name: Optional[str] = None,
|
|
210
|
+
*,
|
|
211
|
+
capture_input: bool = True,
|
|
212
|
+
capture_output: bool = True,
|
|
213
|
+
) -> Callable[[F], F]: ...
|
|
214
|
+
|
|
215
|
+
def observe(
|
|
216
|
+
func: Optional[F] = None,
|
|
217
|
+
name: Optional[str] = None,
|
|
218
|
+
*,
|
|
219
|
+
capture_input: bool = True,
|
|
220
|
+
capture_output: bool = True,
|
|
221
|
+
) -> Union[F, Callable[[F], F]]:
|
|
222
|
+
"""
|
|
223
|
+
General-purpose decorator to observe any function.
|
|
224
|
+
|
|
225
|
+
Can be used with or without arguments:
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> @observe
|
|
229
|
+
... def my_function():
|
|
230
|
+
... pass
|
|
231
|
+
|
|
232
|
+
>>> @observe(name="custom-name", capture_output=False)
|
|
233
|
+
... def another_function():
|
|
234
|
+
... pass
|
|
235
|
+
"""
|
|
236
|
+
def decorator(fn: F) -> F:
|
|
237
|
+
span_name = name or fn.__name__
|
|
238
|
+
is_async = inspect.iscoroutinefunction(fn)
|
|
239
|
+
|
|
240
|
+
if is_async:
|
|
241
|
+
@functools.wraps(fn)
|
|
242
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
243
|
+
client = _get_client()
|
|
244
|
+
if client is None:
|
|
245
|
+
return await fn(*args, **kwargs)
|
|
246
|
+
|
|
247
|
+
tracer = client.tracer
|
|
248
|
+
with tracer.start_as_current_span(
|
|
249
|
+
name=span_name,
|
|
250
|
+
kind=trace.SpanKind.INTERNAL,
|
|
251
|
+
) as span:
|
|
252
|
+
start_time = time.perf_counter()
|
|
253
|
+
span.set_attribute("heimdall.span_kind", SpanKind.INTERNAL.value)
|
|
254
|
+
|
|
255
|
+
if capture_input:
|
|
256
|
+
try:
|
|
257
|
+
all_args = _capture_arguments(fn, args, kwargs)
|
|
258
|
+
span.set_attribute("heimdall.input", _serialize_value(all_args))
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
result = await fn(*args, **kwargs)
|
|
264
|
+
if capture_output:
|
|
265
|
+
span.set_attribute("heimdall.output", _serialize_value(result))
|
|
266
|
+
span.set_status(Status(StatusCode.OK))
|
|
267
|
+
return result
|
|
268
|
+
except Exception as e:
|
|
269
|
+
_record_error(span, e)
|
|
270
|
+
raise
|
|
271
|
+
finally:
|
|
272
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
273
|
+
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
274
|
+
|
|
275
|
+
return async_wrapper # type: ignore
|
|
276
|
+
else:
|
|
277
|
+
@functools.wraps(fn)
|
|
278
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
279
|
+
client = _get_client()
|
|
280
|
+
if client is None:
|
|
281
|
+
return fn(*args, **kwargs)
|
|
282
|
+
|
|
283
|
+
tracer = client.tracer
|
|
284
|
+
with tracer.start_as_current_span(
|
|
285
|
+
name=span_name,
|
|
286
|
+
kind=trace.SpanKind.INTERNAL,
|
|
287
|
+
) as span:
|
|
288
|
+
start_time = time.perf_counter()
|
|
289
|
+
span.set_attribute("heimdall.span_kind", SpanKind.INTERNAL.value)
|
|
290
|
+
|
|
291
|
+
if capture_input:
|
|
292
|
+
try:
|
|
293
|
+
all_args = _capture_arguments(fn, args, kwargs)
|
|
294
|
+
span.set_attribute("heimdall.input", _serialize_value(all_args))
|
|
295
|
+
except Exception:
|
|
296
|
+
pass
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
result = fn(*args, **kwargs)
|
|
300
|
+
if capture_output:
|
|
301
|
+
span.set_attribute("heimdall.output", _serialize_value(result))
|
|
302
|
+
span.set_status(Status(StatusCode.OK))
|
|
303
|
+
return result
|
|
304
|
+
except Exception as e:
|
|
305
|
+
_record_error(span, e)
|
|
306
|
+
raise
|
|
307
|
+
finally:
|
|
308
|
+
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
309
|
+
span.set_attribute(HeimdallAttributes.DURATION_MS, duration_ms)
|
|
310
|
+
|
|
311
|
+
return sync_wrapper # type: ignore
|
|
312
|
+
|
|
313
|
+
# Handle both @observe and @observe() syntax
|
|
314
|
+
if func is not None:
|
|
315
|
+
return decorator(func)
|
|
316
|
+
return decorator
|
|
317
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Type definitions for Heimdall SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Dict, Optional, List
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SpanKind(str, Enum):
|
|
12
|
+
"""Kind of span being recorded."""
|
|
13
|
+
|
|
14
|
+
MCP_TOOL = "mcp.tool"
|
|
15
|
+
MCP_RESOURCE = "mcp.resource"
|
|
16
|
+
MCP_PROMPT = "mcp.prompt"
|
|
17
|
+
MCP_REQUEST = "mcp.request"
|
|
18
|
+
INTERNAL = "internal"
|
|
19
|
+
CLIENT = "client"
|
|
20
|
+
SERVER = "server"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpanStatus(str, Enum):
|
|
24
|
+
"""Status of a span."""
|
|
25
|
+
|
|
26
|
+
UNSET = "unset"
|
|
27
|
+
OK = "ok"
|
|
28
|
+
ERROR = "error"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class MCPToolCall:
|
|
33
|
+
"""Represents an MCP tool call."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
result: Optional[Any] = None
|
|
38
|
+
error: Optional[str] = None
|
|
39
|
+
duration_ms: Optional[float] = None
|
|
40
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class MCPResourceAccess:
|
|
45
|
+
"""Represents an MCP resource access."""
|
|
46
|
+
|
|
47
|
+
uri: str
|
|
48
|
+
method: str = "read"
|
|
49
|
+
content_type: Optional[str] = None
|
|
50
|
+
content_length: Optional[int] = None
|
|
51
|
+
error: Optional[str] = None
|
|
52
|
+
duration_ms: Optional[float] = None
|
|
53
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class MCPPromptCall:
|
|
58
|
+
"""Represents an MCP prompt call."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
arguments: Dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
messages: List[Dict[str, Any]] = field(default_factory=list)
|
|
63
|
+
error: Optional[str] = None
|
|
64
|
+
duration_ms: Optional[float] = None
|
|
65
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class TraceContext:
|
|
70
|
+
"""Context for a trace."""
|
|
71
|
+
|
|
72
|
+
trace_id: str
|
|
73
|
+
span_id: str
|
|
74
|
+
parent_span_id: Optional[str] = None
|
|
75
|
+
session_id: Optional[str] = None
|
|
76
|
+
user_id: Optional[str] = None
|
|
77
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
78
|
+
tags: List[str] = field(default_factory=list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Attribute keys for OpenTelemetry spans
|
|
82
|
+
class HeimdallAttributes:
|
|
83
|
+
"""Standard attribute keys for Heimdall spans."""
|
|
84
|
+
|
|
85
|
+
# MCP specific attributes
|
|
86
|
+
MCP_TOOL_NAME = "mcp.tool.name"
|
|
87
|
+
MCP_TOOL_ARGUMENTS = "mcp.tool.arguments"
|
|
88
|
+
MCP_TOOL_RESULT = "mcp.tool.result"
|
|
89
|
+
|
|
90
|
+
MCP_RESOURCE_URI = "mcp.resource.uri"
|
|
91
|
+
MCP_RESOURCE_METHOD = "mcp.resource.method"
|
|
92
|
+
MCP_RESOURCE_CONTENT_TYPE = "mcp.resource.content_type"
|
|
93
|
+
MCP_RESOURCE_CONTENT_LENGTH = "mcp.resource.content_length"
|
|
94
|
+
|
|
95
|
+
MCP_PROMPT_NAME = "mcp.prompt.name"
|
|
96
|
+
MCP_PROMPT_ARGUMENTS = "mcp.prompt.arguments"
|
|
97
|
+
MCP_PROMPT_MESSAGES = "mcp.prompt.messages"
|
|
98
|
+
|
|
99
|
+
# Heimdall specific attributes
|
|
100
|
+
HEIMDALL_SESSION_ID = "heimdall.session_id"
|
|
101
|
+
HEIMDALL_USER_ID = "heimdall.user_id"
|
|
102
|
+
HEIMDALL_ENVIRONMENT = "heimdall.environment"
|
|
103
|
+
HEIMDALL_SERVICE_NAME = "heimdall.service_name"
|
|
104
|
+
HEIMDALL_ORG_ID = "heimdall.org_id"
|
|
105
|
+
HEIMDALL_PROJECT_ID = "heimdall.project_id"
|
|
106
|
+
|
|
107
|
+
# Status and error attributes
|
|
108
|
+
STATUS = "heimdall.status"
|
|
109
|
+
ERROR_MESSAGE = "heimdall.error.message"
|
|
110
|
+
ERROR_TYPE = "heimdall.error.type"
|
|
111
|
+
|
|
112
|
+
# Timing attributes
|
|
113
|
+
DURATION_MS = "heimdall.duration_ms"
|
|
114
|
+
|