open-edison 0.1.10__py3-none-any.whl → 0.1.15__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.
- {open_edison-0.1.10.dist-info → open_edison-0.1.15.dist-info}/METADATA +5 -2
- open_edison-0.1.15.dist-info/RECORD +18 -0
- src/cli.py +2 -2
- src/config.py +53 -1
- src/frontend_dist/assets/index-_NTxjOfh.js +51 -0
- src/frontend_dist/assets/index-h6k8aL6h.css +1 -0
- src/frontend_dist/index.html +2 -2
- src/middleware/data_access_tracker.py +224 -123
- src/middleware/session_tracking.py +16 -0
- src/server.py +195 -7
- src/telemetry.py +336 -0
- open_edison-0.1.10.dist-info/RECORD +0 -17
- src/frontend_dist/assets/index-CKkid2y-.js +0 -51
- src/frontend_dist/assets/index-CRxojymD.css +0 -1
- {open_edison-0.1.10.dist-info → open_edison-0.1.15.dist-info}/WHEEL +0 -0
- {open_edison-0.1.10.dist-info → open_edison-0.1.15.dist-info}/entry_points.txt +0 -0
- {open_edison-0.1.10.dist-info → open_edison-0.1.15.dist-info}/licenses/LICENSE +0 -0
@@ -29,6 +29,11 @@ from sqlalchemy.sql import select
|
|
29
29
|
|
30
30
|
from src.config import get_config_dir # type: ignore[reportMissingImports]
|
31
31
|
from src.middleware.data_access_tracker import DataAccessTracker
|
32
|
+
from src.telemetry import (
|
33
|
+
record_prompt_used,
|
34
|
+
record_resource_used,
|
35
|
+
record_tool_call,
|
36
|
+
)
|
32
37
|
|
33
38
|
|
34
39
|
@dataclass
|
@@ -165,6 +170,13 @@ def get_session_from_db(session_id: str) -> MCPSession:
|
|
165
170
|
data_access_tracker.has_external_communication = trifecta.get(
|
166
171
|
"has_external_communication", False
|
167
172
|
)
|
173
|
+
# Restore ACL highest level if present
|
174
|
+
if isinstance(summary_data, dict) and "acl" in summary_data:
|
175
|
+
acl_summary: Any = summary_data.get("acl") # type: ignore
|
176
|
+
if isinstance(acl_summary, dict):
|
177
|
+
highest = acl_summary.get("highest_acl_level") # type: ignore
|
178
|
+
if isinstance(highest, str) and highest:
|
179
|
+
data_access_tracker.highest_acl_level = highest
|
168
180
|
|
169
181
|
return MCPSession(
|
170
182
|
session_id=session_id,
|
@@ -285,6 +297,8 @@ class SessionTrackingMiddleware(Middleware):
|
|
285
297
|
assert session.data_access_tracker is not None
|
286
298
|
log.debug(f"🔍 Analyzing tool {context.message.name} for security implications")
|
287
299
|
_ = session.data_access_tracker.add_tool_call(context.message.name)
|
300
|
+
# Telemetry: record tool call
|
301
|
+
record_tool_call(context.message.name)
|
288
302
|
|
289
303
|
# Update database session
|
290
304
|
with create_db_session() as db_session:
|
@@ -383,6 +397,7 @@ class SessionTrackingMiddleware(Middleware):
|
|
383
397
|
|
384
398
|
log.debug(f"🔍 Analyzing resource {resource_name} for security implications")
|
385
399
|
_ = session.data_access_tracker.add_resource_access(resource_name)
|
400
|
+
record_resource_used(resource_name)
|
386
401
|
|
387
402
|
# Update database session
|
388
403
|
with create_db_session() as db_session:
|
@@ -463,6 +478,7 @@ class SessionTrackingMiddleware(Middleware):
|
|
463
478
|
|
464
479
|
log.debug(f"🔍 Analyzing prompt {prompt_name} for security implications")
|
465
480
|
_ = session.data_access_tracker.add_prompt_access(prompt_name)
|
481
|
+
record_prompt_used(prompt_name)
|
466
482
|
|
467
483
|
# Update database session
|
468
484
|
with create_db_session() as db_session:
|
src/server.py
CHANGED
@@ -6,6 +6,7 @@ No multi-user support, no complex routing - just a straightforward proxy.
|
|
6
6
|
"""
|
7
7
|
|
8
8
|
import asyncio
|
9
|
+
import traceback
|
9
10
|
from collections.abc import Awaitable, Callable, Coroutine
|
10
11
|
from pathlib import Path
|
11
12
|
from typing import Any, cast
|
@@ -16,7 +17,9 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
16
17
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
17
18
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
18
19
|
from fastapi.staticfiles import StaticFiles
|
20
|
+
from fastmcp import FastMCP
|
19
21
|
from loguru import logger as log
|
22
|
+
from pydantic import BaseModel, Field
|
20
23
|
|
21
24
|
from src.config import MCPServerConfig, config
|
22
25
|
from src.config import get_config_dir as _get_cfg_dir # type: ignore[attr-defined]
|
@@ -26,6 +29,7 @@ from src.middleware.session_tracking import (
|
|
26
29
|
create_db_session,
|
27
30
|
)
|
28
31
|
from src.single_user_mcp import SingleUserMCP
|
32
|
+
from src.telemetry import initialize_telemetry, set_servers_installed
|
29
33
|
|
30
34
|
|
31
35
|
def _get_current_config():
|
@@ -177,21 +181,33 @@ class OpenEdisonProxy:
|
|
177
181
|
}
|
178
182
|
|
179
183
|
def _resolve_json_path(filename: str) -> Path:
|
180
|
-
|
184
|
+
"""
|
185
|
+
Resolve a JSON config file path consistently with src.config defaults.
|
186
|
+
|
187
|
+
Precedence for reads (and writes if chosen):
|
188
|
+
1) Repository root next to src/ (editable/dev) if file exists
|
189
|
+
2) Config dir (OPEN_EDISON_CONFIG_DIR or platform default)
|
190
|
+
- If missing, bootstrap from repo root default when available
|
191
|
+
3) Current working directory as last resort
|
192
|
+
"""
|
193
|
+
# 1) Prefer repository root next to src/
|
194
|
+
repo_candidate = Path(__file__).parent.parent / filename
|
195
|
+
if repo_candidate.exists():
|
196
|
+
return repo_candidate
|
197
|
+
|
198
|
+
# 2) Config directory
|
181
199
|
try:
|
182
200
|
base = _get_cfg_dir()
|
183
201
|
except Exception:
|
184
202
|
base = Path.cwd()
|
185
203
|
target = base / filename
|
186
|
-
|
187
|
-
if not target.exists():
|
204
|
+
if (not target.exists()) and repo_candidate.exists():
|
188
205
|
try:
|
189
|
-
|
190
|
-
|
191
|
-
target.write_text(pkg_default.read_text(encoding="utf-8"), encoding="utf-8")
|
206
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
207
|
+
target.write_text(repo_candidate.read_text(encoding="utf-8"), encoding="utf-8")
|
192
208
|
except Exception:
|
193
209
|
pass
|
194
|
-
return target
|
210
|
+
return target if target.exists() else repo_candidate
|
195
211
|
|
196
212
|
async def _serve_json(filename: str) -> Response: # type: ignore[override]
|
197
213
|
if filename not in allowed_json_files:
|
@@ -261,12 +277,26 @@ class OpenEdisonProxy:
|
|
261
277
|
|
262
278
|
return app
|
263
279
|
|
280
|
+
def _build_backend_config_top(
|
281
|
+
self, server_name: str, body: "OpenEdisonProxy._ValidateRequest"
|
282
|
+
) -> dict[str, Any]:
|
283
|
+
backend_entry: dict[str, Any] = {
|
284
|
+
"command": body.command,
|
285
|
+
"args": body.args,
|
286
|
+
"env": body.env or {},
|
287
|
+
}
|
288
|
+
if body.roots:
|
289
|
+
backend_entry["roots"] = body.roots
|
290
|
+
return {"mcpServers": {server_name: backend_entry}}
|
291
|
+
|
264
292
|
async def start(self) -> None:
|
265
293
|
"""Start the Open Edison proxy server"""
|
266
294
|
log.info("🚀 Starting Open Edison MCP Proxy Server")
|
267
295
|
log.info(f"FastAPI management API on {self.host}:{self.port + 1}")
|
268
296
|
log.info(f"FastMCP protocol server on {self.host}:{self.port}")
|
269
297
|
|
298
|
+
initialize_telemetry()
|
299
|
+
|
270
300
|
# Ensure the sessions database exists and has the required schema
|
271
301
|
try:
|
272
302
|
with create_db_session():
|
@@ -277,6 +307,10 @@ class OpenEdisonProxy:
|
|
277
307
|
# Initialize the FastMCP server (this handles starting enabled MCP servers)
|
278
308
|
await self.single_user_mcp.initialize()
|
279
309
|
|
310
|
+
# Emit snapshot of enabled servers
|
311
|
+
enabled_count = len([s for s in config.mcp_servers if s.enabled])
|
312
|
+
set_servers_installed(enabled_count)
|
313
|
+
|
280
314
|
# Add CORS middleware to FastAPI
|
281
315
|
self.fastapi_app.add_middleware(
|
282
316
|
CORSMiddleware,
|
@@ -336,6 +370,12 @@ class OpenEdisonProxy:
|
|
336
370
|
methods=["POST"],
|
337
371
|
dependencies=[Depends(self.verify_api_key)],
|
338
372
|
)
|
373
|
+
app.add_api_route(
|
374
|
+
"/mcp/validate",
|
375
|
+
self.validate_mcp_server,
|
376
|
+
methods=["POST"],
|
377
|
+
# Intentionally no auth required for validation for now
|
378
|
+
)
|
339
379
|
app.add_api_route(
|
340
380
|
"/mcp/{server_name}/stop",
|
341
381
|
self.stop_mcp_server,
|
@@ -558,3 +598,151 @@ class OpenEdisonProxy:
|
|
558
598
|
except Exception as e:
|
559
599
|
log.error(f"Failed to fetch sessions: {e}")
|
560
600
|
raise HTTPException(status_code=500, detail="Failed to fetch sessions") from e
|
601
|
+
|
602
|
+
# ---- MCP validation ----
|
603
|
+
class _ValidateRequest(BaseModel):
|
604
|
+
name: str | None = Field(None, description="Optional server name label")
|
605
|
+
command: str = Field(..., description="Executable to run, e.g. 'npx' or 'uvx'")
|
606
|
+
args: list[str] = Field(default_factory=list, description="Arguments to the command")
|
607
|
+
env: dict[str, str] | None = Field(
|
608
|
+
default=None,
|
609
|
+
description="Environment variables for the subprocess (values should already exist)",
|
610
|
+
)
|
611
|
+
roots: list[str] | None = Field(
|
612
|
+
default=None, description="Optional allowed roots for the MCP server"
|
613
|
+
)
|
614
|
+
timeout_s: float | None = Field(20.0, description="Overall timeout for validation")
|
615
|
+
|
616
|
+
async def validate_mcp_server(self, body: _ValidateRequest) -> dict[str, Any]: # noqa: C901
|
617
|
+
"""
|
618
|
+
Validate an MCP server by launching it via FastMCP and listing capabilities.
|
619
|
+
|
620
|
+
Returns tools, resources, and prompts if successful.
|
621
|
+
"""
|
622
|
+
|
623
|
+
server_name = body.name or "validation"
|
624
|
+
backend_cfg = self._build_backend_config_top(server_name, body)
|
625
|
+
|
626
|
+
log.info(
|
627
|
+
f"Validating MCP server command for '{server_name}': {body.command} {' '.join(body.args)}"
|
628
|
+
)
|
629
|
+
|
630
|
+
server: FastMCP[Any] | None = None
|
631
|
+
try:
|
632
|
+
# Guard for template entries with no command configured
|
633
|
+
if not body.command or not body.command.strip():
|
634
|
+
return {
|
635
|
+
"valid": False,
|
636
|
+
"error": "No command configured (template entry)",
|
637
|
+
"server": {
|
638
|
+
"name": server_name,
|
639
|
+
"command": body.command,
|
640
|
+
"args": body.args,
|
641
|
+
"has_roots": bool(body.roots),
|
642
|
+
},
|
643
|
+
}
|
644
|
+
|
645
|
+
server = FastMCP.as_proxy(
|
646
|
+
backend=backend_cfg, name=f"open-edison-validate-{server_name}"
|
647
|
+
)
|
648
|
+
tools, resources, prompts = await self._list_all_capabilities(server, body)
|
649
|
+
|
650
|
+
return {
|
651
|
+
"valid": True,
|
652
|
+
"server": {
|
653
|
+
"name": server_name,
|
654
|
+
"command": body.command,
|
655
|
+
"args": body.args,
|
656
|
+
"has_roots": bool(body.roots),
|
657
|
+
},
|
658
|
+
"tools": [self._safe_tool(t) for t in tools],
|
659
|
+
"resources": [self._safe_resource(r) for r in resources],
|
660
|
+
"prompts": [self._safe_prompt(p) for p in prompts],
|
661
|
+
}
|
662
|
+
except TimeoutError as te: # noqa: PERF203
|
663
|
+
log.error(f"MCP validation timed out: {te}\n{traceback.format_exc()}")
|
664
|
+
return {
|
665
|
+
"valid": False,
|
666
|
+
"error": "Validation timed out",
|
667
|
+
"server": {
|
668
|
+
"name": server_name,
|
669
|
+
"command": body.command,
|
670
|
+
"args": body.args,
|
671
|
+
"has_roots": bool(body.roots),
|
672
|
+
},
|
673
|
+
}
|
674
|
+
except Exception as e: # noqa: BLE001
|
675
|
+
log.error(f"MCP validation failed: {e}\n{traceback.format_exc()}")
|
676
|
+
return {
|
677
|
+
"valid": False,
|
678
|
+
"error": str(e),
|
679
|
+
"server": {
|
680
|
+
"name": server_name,
|
681
|
+
"command": body.command,
|
682
|
+
"args": body.args,
|
683
|
+
"has_roots": bool(body.roots),
|
684
|
+
},
|
685
|
+
}
|
686
|
+
finally:
|
687
|
+
# Best-effort cleanup if FastMCP exposes a shutdown/close
|
688
|
+
try:
|
689
|
+
if isinstance(server, FastMCP):
|
690
|
+
result = server.shutdown() # type: ignore[attr-defined]
|
691
|
+
# If it returns an awaitable, await it
|
692
|
+
if isinstance(result, Awaitable):
|
693
|
+
await result # type: ignore[func-returns-value]
|
694
|
+
except Exception as cleanup_err: # noqa: BLE001
|
695
|
+
log.debug(f"Validator cleanup skipped/failed: {cleanup_err}")
|
696
|
+
|
697
|
+
def _build_backend_config(
|
698
|
+
self, server_name: str, body: "OpenEdisonProxy._ValidateRequest"
|
699
|
+
) -> dict[str, Any]:
|
700
|
+
backend_entry: dict[str, Any] = {
|
701
|
+
"command": body.command,
|
702
|
+
"args": body.args,
|
703
|
+
"env": body.env or {},
|
704
|
+
}
|
705
|
+
if body.roots:
|
706
|
+
backend_entry["roots"] = body.roots
|
707
|
+
return {"mcpServers": {server_name: backend_entry}}
|
708
|
+
|
709
|
+
async def _list_all_capabilities(
|
710
|
+
self, server: FastMCP[Any], body: "OpenEdisonProxy._ValidateRequest"
|
711
|
+
) -> tuple[list[Any], list[Any], list[Any]]:
|
712
|
+
s: Any = server
|
713
|
+
|
714
|
+
async def _call_list(kind: str) -> list[Any]:
|
715
|
+
# Prefer public list_*; fallback to _list_* for proxies that expose private methods
|
716
|
+
for attr in (f"list_{kind}", f"_list_{kind}"):
|
717
|
+
if hasattr(s, attr):
|
718
|
+
method = getattr(s, attr)
|
719
|
+
return await method()
|
720
|
+
raise AttributeError(f"Proxy does not expose list method for {kind}")
|
721
|
+
|
722
|
+
async def list_all() -> tuple[list[Any], list[Any], list[Any]]:
|
723
|
+
tools = await _call_list("tools")
|
724
|
+
resources = await _call_list("resources")
|
725
|
+
prompts = await _call_list("prompts")
|
726
|
+
return tools, resources, prompts
|
727
|
+
|
728
|
+
timeout = body.timeout_s if isinstance(body.timeout_s, (int | float)) else 20.0
|
729
|
+
return await asyncio.wait_for(list_all(), timeout=timeout)
|
730
|
+
|
731
|
+
def _safe_tool(self, t: Any) -> dict[str, Any]:
|
732
|
+
name = getattr(t, "name", None)
|
733
|
+
description = getattr(t, "description", None)
|
734
|
+
return {"name": str(name) if name is not None else "", "description": description}
|
735
|
+
|
736
|
+
def _safe_resource(self, r: Any) -> dict[str, Any]:
|
737
|
+
uri = getattr(r, "uri", None)
|
738
|
+
try:
|
739
|
+
uri_str = str(uri) if uri is not None else ""
|
740
|
+
except Exception:
|
741
|
+
uri_str = ""
|
742
|
+
description = getattr(r, "description", None)
|
743
|
+
return {"uri": uri_str, "description": description}
|
744
|
+
|
745
|
+
def _safe_prompt(self, p: Any) -> dict[str, Any]:
|
746
|
+
name = getattr(p, "name", None)
|
747
|
+
description = getattr(p, "description", None)
|
748
|
+
return {"name": str(name) if name is not None else "", "description": description}
|
src/telemetry.py
ADDED
@@ -0,0 +1,336 @@
|
|
1
|
+
"""
|
2
|
+
Telemetry for Open Edison (opt-out).
|
3
|
+
This module provides a thin, optional wrapper around OpenTelemetry to export
|
4
|
+
basic usage metrics to an OTLP endpoint. If telemetry is disabled or the
|
5
|
+
OpenTelemetry packages are not installed, all functions are safe no-ops.
|
6
|
+
|
7
|
+
Events/metrics captured (high level, install-unique ID for deaggregation):
|
8
|
+
- tool_calls_total (counter)
|
9
|
+
- tool_calls_blocked_total (counter)
|
10
|
+
- servers_installed_total (up-down counter / gauge)
|
11
|
+
- tool_calls_metadata_total (counter)
|
12
|
+
- resource_used_total (counter)
|
13
|
+
- prompt_used_total (counter)
|
14
|
+
- private_data_access_calls_total (counter)
|
15
|
+
- untrusted_public_data_calls_total (counter)
|
16
|
+
- write_operation_calls_total (counter)
|
17
|
+
|
18
|
+
Configuration: see `TelemetryConfig` in `src.config`.
|
19
|
+
"""
|
20
|
+
|
21
|
+
from __future__ import annotations
|
22
|
+
|
23
|
+
import json
|
24
|
+
import os
|
25
|
+
import traceback
|
26
|
+
import uuid
|
27
|
+
from collections.abc import Callable
|
28
|
+
from typing import Any, ParamSpec, TypeVar
|
29
|
+
|
30
|
+
from loguru import logger as log
|
31
|
+
|
32
|
+
# OpenTelemetry metrics components
|
33
|
+
from opentelemetry import metrics as ot_metrics
|
34
|
+
from opentelemetry.exporter.otlp.proto.http import metric_exporter as otlp_metric_exporter
|
35
|
+
from opentelemetry.sdk import metrics as ot_sdk_metrics
|
36
|
+
from opentelemetry.sdk.metrics import export as ot_metrics_export
|
37
|
+
from opentelemetry.sdk.resources import Resource # type: ignore[reportMissingTypeStubs]
|
38
|
+
|
39
|
+
from src.config import TelemetryConfig, config, get_config_dir
|
40
|
+
|
41
|
+
_initialized: bool = False
|
42
|
+
_install_id: str | None = None
|
43
|
+
_provider: Any | None = None
|
44
|
+
_tool_calls_counter: Any | None = None
|
45
|
+
_tool_calls_blocked_counter: Any | None = None
|
46
|
+
_servers_installed_gauge: Any | None = None
|
47
|
+
_tool_calls_metadata_counter: Any | None = None
|
48
|
+
_resource_used_counter: Any | None = None
|
49
|
+
_prompt_used_counter: Any | None = None
|
50
|
+
_private_data_access_counter: Any | None = None
|
51
|
+
_untrusted_public_data_counter: Any | None = None
|
52
|
+
_write_operation_counter: Any | None = None
|
53
|
+
_resource_access_blocked_counter: Any | None = None
|
54
|
+
_prompt_access_blocked_counter: Any | None = None
|
55
|
+
|
56
|
+
|
57
|
+
def _ensure_install_id() -> str:
|
58
|
+
"""Create or read a persistent install-unique ID under the config dir."""
|
59
|
+
global _install_id
|
60
|
+
if _install_id:
|
61
|
+
return _install_id
|
62
|
+
try:
|
63
|
+
cfg_dir = get_config_dir()
|
64
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
65
|
+
except Exception: # noqa: BLE001
|
66
|
+
log.error(
|
67
|
+
"Could not resolve or create config dir for install_id; using ephemeral ID\n{}",
|
68
|
+
traceback.format_exc(),
|
69
|
+
)
|
70
|
+
_install_id = str(uuid.uuid4())
|
71
|
+
return _install_id
|
72
|
+
|
73
|
+
id_file = cfg_dir / "install_id"
|
74
|
+
if id_file.exists():
|
75
|
+
try:
|
76
|
+
_install_id = id_file.read_text(encoding="utf-8").strip() or str(uuid.uuid4())
|
77
|
+
except Exception: # noqa: BLE001
|
78
|
+
log.error(
|
79
|
+
"Failed reading install_id file; using ephemeral ID\n{}",
|
80
|
+
traceback.format_exc(),
|
81
|
+
)
|
82
|
+
_install_id = str(uuid.uuid4())
|
83
|
+
else:
|
84
|
+
_install_id = str(uuid.uuid4())
|
85
|
+
try:
|
86
|
+
id_file.write_text(_install_id, encoding="utf-8")
|
87
|
+
except Exception: # noqa: BLE001
|
88
|
+
log.error(
|
89
|
+
"Failed writing install_id file; continuing without persistence\n{}",
|
90
|
+
traceback.format_exc(),
|
91
|
+
)
|
92
|
+
return _install_id
|
93
|
+
|
94
|
+
|
95
|
+
def _telemetry_enabled() -> bool:
|
96
|
+
tel_cfg = config.telemetry or TelemetryConfig()
|
97
|
+
return bool(tel_cfg.enabled)
|
98
|
+
|
99
|
+
|
100
|
+
P = ParamSpec("P")
|
101
|
+
R = TypeVar("R")
|
102
|
+
|
103
|
+
|
104
|
+
def telemetry_recorder(func: Callable[P, R]) -> Callable[P, R | None]: # noqa: UP047
|
105
|
+
"""No-op when disabled, ensure init, and catch/log failures."""
|
106
|
+
|
107
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: # type: ignore[override]
|
108
|
+
if not _telemetry_enabled():
|
109
|
+
return None
|
110
|
+
if not _initialized:
|
111
|
+
initialize_telemetry()
|
112
|
+
try:
|
113
|
+
return func(*args, **kwargs)
|
114
|
+
except Exception: # noqa: BLE001
|
115
|
+
log.error("Telemetry emit failed\n{}", traceback.format_exc())
|
116
|
+
return None
|
117
|
+
|
118
|
+
return wrapper
|
119
|
+
|
120
|
+
|
121
|
+
def initialize_telemetry(override: TelemetryConfig | None = None) -> None: # noqa: C901
|
122
|
+
"""Initialize telemetry if enabled in config.
|
123
|
+
|
124
|
+
Safe to call multiple times; only first call initializes.
|
125
|
+
"""
|
126
|
+
global \
|
127
|
+
_initialized, \
|
128
|
+
_provider, \
|
129
|
+
_tool_calls_counter, \
|
130
|
+
_tool_calls_blocked_counter, \
|
131
|
+
_servers_installed_gauge
|
132
|
+
|
133
|
+
if _initialized:
|
134
|
+
return
|
135
|
+
|
136
|
+
telemetry_cfg = override if override is not None else (config.telemetry or TelemetryConfig())
|
137
|
+
if not telemetry_cfg.enabled:
|
138
|
+
log.debug("Telemetry disabled by config")
|
139
|
+
_initialized = True
|
140
|
+
return
|
141
|
+
|
142
|
+
# Exporter
|
143
|
+
exporter_kwargs: dict[str, Any] = {}
|
144
|
+
if telemetry_cfg.otlp_endpoint:
|
145
|
+
exporter_kwargs["endpoint"] = telemetry_cfg.otlp_endpoint
|
146
|
+
# Allow environment variables to provide endpoint when not set in config
|
147
|
+
env_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT") or os.environ.get(
|
148
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT"
|
149
|
+
)
|
150
|
+
if "endpoint" not in exporter_kwargs and env_endpoint:
|
151
|
+
exporter_kwargs["endpoint"] = env_endpoint
|
152
|
+
# If no endpoint is available from config or env, skip initialization quietly
|
153
|
+
if "endpoint" not in exporter_kwargs:
|
154
|
+
log.debug("No OTLP endpoint configured (config or env); skipping telemetry init")
|
155
|
+
_initialized = True
|
156
|
+
return
|
157
|
+
if telemetry_cfg.headers:
|
158
|
+
exporter_kwargs["headers"] = telemetry_cfg.headers
|
159
|
+
|
160
|
+
try:
|
161
|
+
exporter: Any = otlp_metric_exporter.OTLPMetricExporter(**exporter_kwargs)
|
162
|
+
except Exception: # noqa: BLE001
|
163
|
+
log.error("OTLP exporter init failed\n{}", traceback.format_exc())
|
164
|
+
return
|
165
|
+
|
166
|
+
# Reader
|
167
|
+
try:
|
168
|
+
reader: Any = ot_metrics_export.PeriodicExportingMetricReader(
|
169
|
+
exporter=exporter,
|
170
|
+
export_interval_millis=max(1000, telemetry_cfg.export_interval_ms),
|
171
|
+
)
|
172
|
+
except Exception: # noqa: BLE001
|
173
|
+
log.error("OTLP reader init failed\n{}", traceback.format_exc())
|
174
|
+
return
|
175
|
+
|
176
|
+
# Provider/meter
|
177
|
+
try:
|
178
|
+
# Attach a resource so metrics include service identifiers
|
179
|
+
resource = Resource.create(
|
180
|
+
{
|
181
|
+
"service.name": "open-edison",
|
182
|
+
"service.namespace": "open-edison",
|
183
|
+
"telemetry.sdk.language": "python",
|
184
|
+
}
|
185
|
+
)
|
186
|
+
provider: Any = ot_sdk_metrics.MeterProvider(metric_readers=[reader], resource=resource)
|
187
|
+
_provider = provider
|
188
|
+
ot_metrics.set_meter_provider(provider)
|
189
|
+
meter: Any = ot_metrics.get_meter("open-edison")
|
190
|
+
except Exception: # noqa: BLE001
|
191
|
+
log.error("Metrics provider init failed\n{}", traceback.format_exc())
|
192
|
+
return
|
193
|
+
|
194
|
+
# Instruments
|
195
|
+
try:
|
196
|
+
# Do not suffix counters with _total; Prometheus exporter appends it
|
197
|
+
_tool_calls_counter = meter.create_counter("tool_calls")
|
198
|
+
_tool_calls_blocked_counter = meter.create_counter("tool_calls_blocked")
|
199
|
+
_servers_installed_gauge = meter.create_up_down_counter("servers_installed")
|
200
|
+
_tool_calls_metadata_counter = meter.create_counter("tool_calls_metadata")
|
201
|
+
_resource_used_counter = meter.create_counter("resource_used")
|
202
|
+
_resource_access_blocked_counter = meter.create_counter("resource_access_blocked")
|
203
|
+
_prompt_used_counter = meter.create_counter("prompt_used")
|
204
|
+
_prompt_access_blocked_counter = meter.create_counter("prompt_access_blocked")
|
205
|
+
_private_data_access_counter = meter.create_counter("private_data_access_calls")
|
206
|
+
_untrusted_public_data_counter = meter.create_counter("untrusted_public_data_calls")
|
207
|
+
_write_operation_counter = meter.create_counter("write_operation_calls")
|
208
|
+
except Exception: # noqa: BLE001
|
209
|
+
log.error("Metrics instrument creation failed\n{}", traceback.format_exc())
|
210
|
+
return
|
211
|
+
|
212
|
+
_ = _ensure_install_id()
|
213
|
+
_initialized = True
|
214
|
+
log.info("📈 Telemetry initialized")
|
215
|
+
|
216
|
+
|
217
|
+
def force_flush_metrics(timeout_ms: int = 5000) -> bool:
|
218
|
+
"""Force-flush metrics synchronously if a provider is initialized.
|
219
|
+
|
220
|
+
Returns True on success, False otherwise.
|
221
|
+
"""
|
222
|
+
try:
|
223
|
+
provider = _provider
|
224
|
+
if provider is None:
|
225
|
+
return False
|
226
|
+
# Some providers expose force_flush(timeout_millis=...), others as force_flush() -> bool
|
227
|
+
if hasattr(provider, "force_flush"):
|
228
|
+
try:
|
229
|
+
# Try with timeout argument first
|
230
|
+
result = provider.force_flush(timeout_millis=timeout_ms) # type: ignore[misc]
|
231
|
+
except TypeError:
|
232
|
+
result = provider.force_flush()
|
233
|
+
return bool(result)
|
234
|
+
return False
|
235
|
+
except Exception: # noqa: BLE001
|
236
|
+
log.error("Force flush failed\n{}", traceback.format_exc())
|
237
|
+
return False
|
238
|
+
|
239
|
+
|
240
|
+
def _common_attrs(extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
241
|
+
attrs: dict[str, Any] = {"install_id": _ensure_install_id(), "app": "open-edison"}
|
242
|
+
if extra:
|
243
|
+
attrs.update(extra)
|
244
|
+
return attrs
|
245
|
+
|
246
|
+
|
247
|
+
@telemetry_recorder
|
248
|
+
def record_tool_call(tool_name: str) -> None:
|
249
|
+
if _tool_calls_counter is None:
|
250
|
+
return
|
251
|
+
_tool_calls_counter.add(1, attributes=_common_attrs({"tool": tool_name}))
|
252
|
+
|
253
|
+
|
254
|
+
@telemetry_recorder
|
255
|
+
def record_tool_call_blocked(tool_name: str, reason: str) -> None:
|
256
|
+
if _tool_calls_blocked_counter is None:
|
257
|
+
return
|
258
|
+
_tool_calls_blocked_counter.add(
|
259
|
+
1, attributes=_common_attrs({"tool": tool_name, "reason": reason})
|
260
|
+
)
|
261
|
+
|
262
|
+
|
263
|
+
@telemetry_recorder
|
264
|
+
def record_tool_call_metadata(tool_name: str, metadata: dict[str, Any]) -> None:
|
265
|
+
if _tool_calls_metadata_counter is None:
|
266
|
+
return
|
267
|
+
metadata_str = json.dumps(metadata, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
268
|
+
_tool_calls_metadata_counter.add(
|
269
|
+
1, attributes=_common_attrs({"tool": tool_name, "metadata_json": metadata_str})
|
270
|
+
)
|
271
|
+
|
272
|
+
|
273
|
+
@telemetry_recorder
|
274
|
+
def set_servers_installed(count: int) -> None:
|
275
|
+
if _servers_installed_gauge is None:
|
276
|
+
return
|
277
|
+
_servers_installed_gauge.add(count, attributes=_common_attrs({"state": "snapshot"}))
|
278
|
+
|
279
|
+
|
280
|
+
@telemetry_recorder
|
281
|
+
def record_resource_used(resource_name: str) -> None:
|
282
|
+
if _resource_used_counter is None:
|
283
|
+
return
|
284
|
+
_resource_used_counter.add(1, attributes=_common_attrs({"resource": resource_name}))
|
285
|
+
|
286
|
+
|
287
|
+
@telemetry_recorder
|
288
|
+
def record_resource_access_blocked(resource_name: str, reason: str) -> None:
|
289
|
+
if _resource_access_blocked_counter is None:
|
290
|
+
return
|
291
|
+
_resource_access_blocked_counter.add(
|
292
|
+
1, attributes=_common_attrs({"resource": resource_name, "reason": reason})
|
293
|
+
)
|
294
|
+
|
295
|
+
|
296
|
+
@telemetry_recorder
|
297
|
+
def record_prompt_used(prompt_name: str) -> None:
|
298
|
+
if _prompt_used_counter is None:
|
299
|
+
return
|
300
|
+
_prompt_used_counter.add(1, attributes=_common_attrs({"prompt": prompt_name}))
|
301
|
+
|
302
|
+
|
303
|
+
@telemetry_recorder
|
304
|
+
def record_prompt_access_blocked(prompt_name: str, reason: str) -> None:
|
305
|
+
if _prompt_access_blocked_counter is None:
|
306
|
+
return
|
307
|
+
_prompt_access_blocked_counter.add(
|
308
|
+
1, attributes=_common_attrs({"prompt": prompt_name, "reason": reason})
|
309
|
+
)
|
310
|
+
|
311
|
+
|
312
|
+
@telemetry_recorder
|
313
|
+
def record_private_data_access(source_type: str, name: str) -> None:
|
314
|
+
if _private_data_access_counter is None:
|
315
|
+
return
|
316
|
+
_private_data_access_counter.add(
|
317
|
+
1, attributes=_common_attrs({"source_type": source_type, "name": name})
|
318
|
+
)
|
319
|
+
|
320
|
+
|
321
|
+
@telemetry_recorder
|
322
|
+
def record_untrusted_public_data(source_type: str, name: str) -> None:
|
323
|
+
if _untrusted_public_data_counter is None:
|
324
|
+
return
|
325
|
+
_untrusted_public_data_counter.add(
|
326
|
+
1, attributes=_common_attrs({"source_type": source_type, "name": name})
|
327
|
+
)
|
328
|
+
|
329
|
+
|
330
|
+
@telemetry_recorder
|
331
|
+
def record_write_operation(source_type: str, name: str) -> None:
|
332
|
+
if _write_operation_counter is None:
|
333
|
+
return
|
334
|
+
_write_operation_counter.add(
|
335
|
+
1, attributes=_common_attrs({"source_type": source_type, "name": name})
|
336
|
+
)
|
@@ -1,17 +0,0 @@
|
|
1
|
-
src/__init__.py,sha256=QWeZdjAm2D2B0eWhd8m2-DPpWvIP26KcNJxwEoU1oEQ,254
|
2
|
-
src/__main__.py,sha256=kQsaVyzRa_ESC57JpKDSQJAHExuXme0rM5beJsYxFeA,161
|
3
|
-
src/cli.py,sha256=ketV-e9oQMVlLBjZR7YbK33XkEfqxPyzWqYkS1YwqYc,9968
|
4
|
-
src/config.py,sha256=klWrNycPxzVt9wPhiNbjXMkB4bHZplenfWDx-3UtQac,7120
|
5
|
-
src/mcp_manager.py,sha256=VpRdVMy1WLegC-gBnyTcBMcKzQsdIn4JIWuHf7Q40hg,4442
|
6
|
-
src/server.py,sha256=7hwhutP0qZ_mjZfs6jcB-UNe_VyibFKl6hPyHWoa-ns,22896
|
7
|
-
src/single_user_mcp.py,sha256=ue5UnC0nfmuLR4z87904WqH7B-0FaACFDWaBNNL7hXE,15259
|
8
|
-
src/frontend_dist/index.html,sha256=CL9uiDUygp5_5_VpsW4WMgYFsMAfVSueYit_vFgX0Qo,673
|
9
|
-
src/frontend_dist/assets/index-CKkid2y-.js,sha256=zaZ7j0nyGkywXAMuCrhZLaSOVqLu7JkQG3wE_8QiFT4,219537
|
10
|
-
src/frontend_dist/assets/index-CRxojymD.css,sha256=kANM9zPkbS5aLrPzePZK0Fbt580I6kNnyFjkFH13HtA,11383
|
11
|
-
src/middleware/data_access_tracker.py,sha256=JkwZdtMCiVU7JJZDd-GhlowW2szMDnXrD95nhxQVXR4,21165
|
12
|
-
src/middleware/session_tracking.py,sha256=rWZh4UBQbqzPh4p6vxdtRwEC1uzq93yjzxcI9LnlRkA,19307
|
13
|
-
open_edison-0.1.10.dist-info/METADATA,sha256=15i5EIVlRNQtBIs3RJTTwiTPXEfF2FYy2a3W2KoBN3g,8834
|
14
|
-
open_edison-0.1.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
15
|
-
open_edison-0.1.10.dist-info/entry_points.txt,sha256=qNAkJcnoTXRhj8J--3PDmXz_TQKdB8H_0C9wiCtDIyA,72
|
16
|
-
open_edison-0.1.10.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
17
|
-
open_edison-0.1.10.dist-info/RECORD,,
|