idun-agent-engine 0.3.8__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- idun_agent_engine/_version.py +1 -1
- idun_agent_engine/agent/langgraph/langgraph.py +1 -1
- idun_agent_engine/core/app_factory.py +1 -1
- idun_agent_engine/core/config_builder.py +5 -6
- idun_agent_engine/guardrails/guardrails_hub/__init__.py +2 -2
- idun_agent_engine/mcp/__init__.py +18 -2
- idun_agent_engine/mcp/helpers.py +95 -45
- idun_agent_engine/mcp/registry.py +7 -1
- idun_agent_engine/server/lifespan.py +22 -0
- idun_agent_engine/telemetry/__init__.py +19 -0
- idun_agent_engine/telemetry/config.py +29 -0
- idun_agent_engine/telemetry/telemetry.py +248 -0
- {idun_agent_engine-0.3.8.dist-info → idun_agent_engine-0.4.0.dist-info}/METADATA +12 -8
- {idun_agent_engine-0.3.8.dist-info → idun_agent_engine-0.4.0.dist-info}/RECORD +39 -14
- idun_platform_cli/groups/init.py +23 -0
- idun_platform_cli/main.py +3 -0
- idun_platform_cli/tui/__init__.py +0 -0
- idun_platform_cli/tui/css/__init__.py +0 -0
- idun_platform_cli/tui/css/create_agent.py +789 -0
- idun_platform_cli/tui/css/main.py +92 -0
- idun_platform_cli/tui/main.py +87 -0
- idun_platform_cli/tui/schemas/__init__.py +0 -0
- idun_platform_cli/tui/schemas/create_agent.py +60 -0
- idun_platform_cli/tui/screens/__init__.py +0 -0
- idun_platform_cli/tui/screens/create_agent.py +482 -0
- idun_platform_cli/tui/utils/__init__.py +0 -0
- idun_platform_cli/tui/utils/config.py +161 -0
- idun_platform_cli/tui/validators/__init__.py +0 -0
- idun_platform_cli/tui/validators/guardrails.py +76 -0
- idun_platform_cli/tui/validators/mcps.py +84 -0
- idun_platform_cli/tui/validators/observability.py +65 -0
- idun_platform_cli/tui/widgets/__init__.py +15 -0
- idun_platform_cli/tui/widgets/guardrails_widget.py +348 -0
- idun_platform_cli/tui/widgets/identity_widget.py +234 -0
- idun_platform_cli/tui/widgets/mcps_widget.py +230 -0
- idun_platform_cli/tui/widgets/observability_widget.py +384 -0
- idun_platform_cli/tui/widgets/serve_widget.py +78 -0
- {idun_agent_engine-0.3.8.dist-info → idun_agent_engine-0.4.0.dist-info}/WHEEL +0 -0
- {idun_agent_engine-0.3.8.dist-info → idun_agent_engine-0.4.0.dist-info}/entry_points.txt +0 -0
idun_agent_engine/_version.py
CHANGED
|
@@ -202,7 +202,7 @@ class LanggraphAgent(agent_base.BaseAgent):
|
|
|
202
202
|
self._agent_instance = graph_builder.compile(
|
|
203
203
|
checkpointer=self._checkpointer, store=self._store
|
|
204
204
|
)
|
|
205
|
-
elif isinstance(graph_builder, CompiledStateGraph):
|
|
205
|
+
elif isinstance(graph_builder, CompiledStateGraph): # TODO: to remove, dirty fix for template deepagent langgraph
|
|
206
206
|
self._agent_instance = graph_builder
|
|
207
207
|
|
|
208
208
|
self._copilotkit_agent_instance = LangGraphAGUIAgent(
|
|
@@ -10,12 +10,12 @@ from typing import Any
|
|
|
10
10
|
from fastapi import FastAPI
|
|
11
11
|
from fastapi.middleware.cors import CORSMiddleware
|
|
12
12
|
|
|
13
|
+
from .._version import __version__
|
|
13
14
|
from ..server.lifespan import lifespan
|
|
14
15
|
from ..server.routers.agent import agent_router
|
|
15
16
|
from ..server.routers.base import base_router
|
|
16
17
|
from .config_builder import ConfigBuilder
|
|
17
18
|
from .engine_config import EngineConfig
|
|
18
|
-
from .._version import __version__
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def create_app(
|
|
@@ -4,28 +4,27 @@ This module provides a fluent API for building configuration objects using Pydan
|
|
|
4
4
|
This approach ensures type safety, validation, and consistency with the rest of the codebase.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import os
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
from typing import Any
|
|
10
9
|
|
|
11
|
-
from idun_agent_schema.engine.guardrails import Guardrails as GuardrailsV1
|
|
12
10
|
import yaml
|
|
11
|
+
from idun_agent_schema.engine.adk import AdkAgentConfig
|
|
13
12
|
from idun_agent_schema.engine.agent_framework import AgentFramework
|
|
13
|
+
from idun_agent_schema.engine.guardrails_v2 import GuardrailsV2 as Guardrails
|
|
14
14
|
from idun_agent_schema.engine.haystack import HaystackAgentConfig
|
|
15
15
|
from idun_agent_schema.engine.langgraph import (
|
|
16
16
|
LangGraphAgentConfig,
|
|
17
17
|
SqliteCheckpointConfig,
|
|
18
18
|
)
|
|
19
|
-
from idun_agent_schema.engine.adk import AdkAgentConfig
|
|
20
19
|
from idun_agent_schema.engine.mcp_server import MCPServer
|
|
21
20
|
from idun_agent_schema.engine.observability_v2 import ObservabilityConfig
|
|
22
|
-
from idun_agent_schema.
|
|
23
|
-
from idun_agent_engine.server.server_config import ServerAPIConfig
|
|
21
|
+
from idun_agent_schema.manager.guardrail_configs import convert_guardrail
|
|
24
22
|
from yaml import YAMLError
|
|
25
23
|
|
|
24
|
+
from idun_agent_engine.server.server_config import ServerAPIConfig
|
|
25
|
+
|
|
26
26
|
from ..agent.base import BaseAgent
|
|
27
27
|
from .engine_config import AgentConfig, EngineConfig, ServerConfig
|
|
28
|
-
from idun_agent_schema.manager.guardrail_configs import convert_guardrail
|
|
29
28
|
|
|
30
29
|
|
|
31
30
|
class ConfigBuilder:
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
"""MCP utilities for Idun Agent Engine."""
|
|
2
2
|
|
|
3
3
|
from .registry import MCPClientRegistry
|
|
4
|
-
from .helpers import
|
|
5
|
-
|
|
4
|
+
from .helpers import (
|
|
5
|
+
get_adk_tools_from_api,
|
|
6
|
+
get_adk_tools_from_file,
|
|
7
|
+
get_adk_tools,
|
|
8
|
+
get_langchain_tools,
|
|
9
|
+
get_langchain_tools_from_api,
|
|
10
|
+
get_langchain_tools_from_file,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MCPClientRegistry",
|
|
15
|
+
"get_adk_tools_from_api",
|
|
16
|
+
"get_adk_tools_from_file",
|
|
17
|
+
"get_adk_tools",
|
|
18
|
+
"get_langchain_tools",
|
|
19
|
+
"get_langchain_tools_from_api",
|
|
20
|
+
"get_langchain_tools_from_file",
|
|
21
|
+
]
|
idun_agent_engine/mcp/helpers.py
CHANGED
|
@@ -6,36 +6,52 @@ import os
|
|
|
6
6
|
from idun_agent_engine.mcp.registry import MCPClientRegistry
|
|
7
7
|
from idun_agent_schema.engine.mcp_server import MCPServer
|
|
8
8
|
|
|
9
|
-
def
|
|
10
|
-
"""
|
|
11
|
-
# Handle both snake_case and camelCase for mcp_servers
|
|
12
|
-
# Note: logic in ConfigBuilder suggests looking inside 'engine_config' if present,
|
|
13
|
-
# but this helper expects the dictionary containing 'mcp_servers' directly
|
|
14
|
-
# or performs the search itself.
|
|
15
|
-
|
|
9
|
+
def _extract_mcp_configs(config_data: dict[str, Any]) -> list[MCPServer]:
|
|
10
|
+
"""Parse MCP server configs from a config dictionary."""
|
|
16
11
|
mcp_configs_data = config_data.get("mcp_servers") or config_data.get("mcpServers")
|
|
17
|
-
|
|
18
12
|
if not mcp_configs_data:
|
|
19
13
|
return []
|
|
14
|
+
return [MCPServer.model_validate(c) for c in mcp_configs_data]
|
|
20
15
|
|
|
21
|
-
mcp_configs = [MCPServer.model_validate(c) for c in mcp_configs_data]
|
|
22
|
-
registry = MCPClientRegistry(mcp_configs)
|
|
23
16
|
|
|
17
|
+
def _unwrap_engine_config(config_data: dict[str, Any]) -> dict[str, Any]:
|
|
18
|
+
"""Return engine-level config if wrapped under engine_config."""
|
|
19
|
+
if not isinstance(config_data, dict):
|
|
20
|
+
raise ValueError("Configuration payload is empty or invalid")
|
|
21
|
+
if "engine_config" in config_data:
|
|
22
|
+
return config_data["engine_config"]
|
|
23
|
+
return config_data
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_registry(config_data: dict[str, Any]) -> MCPClientRegistry | None:
|
|
27
|
+
"""Instantiate an MCP client registry from config data."""
|
|
28
|
+
mcp_configs = _extract_mcp_configs(config_data)
|
|
29
|
+
if not mcp_configs:
|
|
30
|
+
return None
|
|
31
|
+
return MCPClientRegistry(mcp_configs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_toolsets_from_data(config_data: dict[str, Any]) -> list[Any]:
|
|
35
|
+
"""Internal helper to extract ADK toolsets from config dictionary."""
|
|
36
|
+
registry = _build_registry(config_data)
|
|
37
|
+
if not registry:
|
|
38
|
+
return []
|
|
24
39
|
try:
|
|
25
40
|
return registry.get_adk_toolsets()
|
|
26
41
|
except ImportError:
|
|
27
42
|
raise
|
|
28
43
|
|
|
29
|
-
def get_adk_tools_from_file(config_path: str | Path) -> list[Any]:
|
|
30
|
-
"""
|
|
31
|
-
Loads MCP configurations from a YAML file and returns a list of ADK toolsets.
|
|
32
44
|
|
|
33
|
-
|
|
34
|
-
|
|
45
|
+
async def _get_langchain_tools_from_data(config_data: dict[str, Any]) -> list[Any]:
|
|
46
|
+
"""Internal helper to extract LangChain tools from config dictionary."""
|
|
47
|
+
registry = _build_registry(config_data)
|
|
48
|
+
if not registry:
|
|
49
|
+
return []
|
|
50
|
+
return await registry.get_tools()
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"""
|
|
52
|
+
|
|
53
|
+
def _load_config_from_file(config_path: str | Path) -> dict[str, Any]:
|
|
54
|
+
"""Load YAML config (optionally wrapped in engine_config) from disk."""
|
|
39
55
|
path = Path(config_path)
|
|
40
56
|
if not path.exists():
|
|
41
57
|
raise FileNotFoundError(f"Configuration file not found at {path}")
|
|
@@ -43,55 +59,89 @@ def get_adk_tools_from_file(config_path: str | Path) -> list[Any]:
|
|
|
43
59
|
with open(path) as f:
|
|
44
60
|
config_data = yaml.safe_load(f)
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
if "engine_config" in config_data:
|
|
48
|
-
config_data = config_data["engine_config"]
|
|
62
|
+
return _unwrap_engine_config(config_data)
|
|
49
63
|
|
|
50
|
-
return _get_toolsets_from_data(config_data)
|
|
51
64
|
|
|
52
|
-
def
|
|
53
|
-
"""
|
|
54
|
-
Fetches configuration from the Idun Manager API and returns a list of ADK toolsets.
|
|
55
|
-
|
|
56
|
-
Args:
|
|
57
|
-
agent_api_key: The API key for authentication.
|
|
58
|
-
manager_url: The base URL of the Idun Manager (e.g. http://localhost:8000).
|
|
59
|
-
|
|
60
|
-
Returns:
|
|
61
|
-
List of initialized ADK McpToolset instances.
|
|
62
|
-
"""
|
|
65
|
+
def _fetch_config_from_api() -> dict[str, Any]:
|
|
66
|
+
"""Fetch configuration from the Idun Manager API."""
|
|
63
67
|
api_key = os.environ.get("IDUN_AGENT_API_KEY")
|
|
64
68
|
manager_host = os.environ.get("IDUN_MANAGER_HOST")
|
|
69
|
+
|
|
70
|
+
if not api_key:
|
|
71
|
+
raise ValueError("Environment variable 'IDUN_AGENT_API_KEY' is not set")
|
|
72
|
+
|
|
73
|
+
if not manager_host:
|
|
74
|
+
raise ValueError("Environment variable 'IDUN_MANAGER_HOST' is not set")
|
|
75
|
+
|
|
65
76
|
headers = {"auth": f"Bearer {api_key}"}
|
|
66
77
|
url = f"{manager_host.rstrip('/')}/api/v1/agents/config"
|
|
67
78
|
|
|
68
79
|
try:
|
|
69
80
|
response = requests.get(url=url, headers=headers)
|
|
70
81
|
response.raise_for_status()
|
|
71
|
-
|
|
72
82
|
config_data = yaml.safe_load(response.text)
|
|
73
|
-
|
|
74
|
-
# Config from API is typically wrapped in engine_config
|
|
75
|
-
if "engine_config" in config_data:
|
|
76
|
-
config_data = config_data["engine_config"]
|
|
77
|
-
|
|
78
|
-
return _get_toolsets_from_data(config_data)
|
|
79
|
-
|
|
83
|
+
return _unwrap_engine_config(config_data)
|
|
80
84
|
except requests.RequestException as e:
|
|
81
85
|
raise ValueError(f"Failed to fetch config from API: {e}") from e
|
|
82
86
|
except yaml.YAMLError as e:
|
|
83
87
|
raise ValueError(f"Failed to parse config YAML: {e}") from e
|
|
84
88
|
|
|
89
|
+
def get_adk_tools_from_file(config_path: str | Path) -> list[Any]:
|
|
90
|
+
"""
|
|
91
|
+
Loads MCP configurations from a YAML file and returns a list of ADK toolsets.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
config_path: Path to the configuration YAML file.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
List of initialized ADK McpToolset instances.
|
|
98
|
+
"""
|
|
99
|
+
config_data = _load_config_from_file(config_path)
|
|
100
|
+
return _get_toolsets_from_data(config_data)
|
|
85
101
|
|
|
86
|
-
def
|
|
102
|
+
def get_adk_tools_from_api() -> list[Any]:
|
|
87
103
|
"""
|
|
88
104
|
Fetches configuration from the Idun Manager API and returns a list of ADK toolsets.
|
|
89
105
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
106
|
+
Returns:
|
|
107
|
+
List of initialized ADK McpToolset instances.
|
|
108
|
+
"""
|
|
109
|
+
config_data = _fetch_config_from_api()
|
|
110
|
+
return _get_toolsets_from_data(config_data)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_adk_tools(config_path: str | Path | None = None) -> list[Any]:
|
|
114
|
+
"""
|
|
115
|
+
Returns ADK toolsets using config from file when provided, otherwise from API.
|
|
93
116
|
|
|
94
117
|
Returns:
|
|
95
118
|
List of initialized ADK McpToolset instances.
|
|
96
119
|
"""
|
|
120
|
+
if config_path:
|
|
121
|
+
return get_adk_tools_from_file(config_path)
|
|
97
122
|
return get_adk_tools_from_api()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def get_langchain_tools_from_file(config_path: str | Path) -> list[Any]:
|
|
126
|
+
"""
|
|
127
|
+
Loads MCP configurations from a YAML file and returns LangChain tool instances.
|
|
128
|
+
"""
|
|
129
|
+
config_data = _load_config_from_file(config_path)
|
|
130
|
+
return await _get_langchain_tools_from_data(config_data)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_langchain_tools_from_api() -> list[Any]:
|
|
134
|
+
"""
|
|
135
|
+
Fetches configuration from the Idun Manager API and returns LangChain tool instances.
|
|
136
|
+
"""
|
|
137
|
+
config_data = _fetch_config_from_api()
|
|
138
|
+
return await _get_langchain_tools_from_data(config_data)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def get_langchain_tools(config_path: str | Path | None = None) -> list[Any]:
|
|
142
|
+
"""
|
|
143
|
+
Returns LangChain tool instances using config from file when provided, otherwise from API.
|
|
144
|
+
"""
|
|
145
|
+
if config_path:
|
|
146
|
+
return await get_langchain_tools_from_file(config_path)
|
|
147
|
+
return await get_langchain_tools_from_api()
|
|
@@ -79,7 +79,13 @@ class MCPClientRegistry:
|
|
|
79
79
|
raise RuntimeError("MCP client registry is not enabled.")
|
|
80
80
|
return await self._client.get_tools(server_name=name)
|
|
81
81
|
|
|
82
|
-
def
|
|
82
|
+
async def get_langchain_tools(self, name: str | None = None) -> list[Any]:
|
|
83
|
+
"""
|
|
84
|
+
Alias for get_tools to make intent explicit when using LangChain/LangGraph agents.
|
|
85
|
+
"""
|
|
86
|
+
return await self.get_tools(name=name)
|
|
87
|
+
|
|
88
|
+
def get_adk_toolsets(self) -> list[Any]:
|
|
83
89
|
"""Return a list of Google ADK McpToolset instances for configured servers."""
|
|
84
90
|
if McpToolset is None or StdioServerParameters is None:
|
|
85
91
|
raise ImportError("google-adk and mcp packages are required for ADK toolsets.")
|
|
@@ -15,6 +15,7 @@ from ..mcp import MCPClientRegistry
|
|
|
15
15
|
from idun_agent_schema.engine.guardrails import Guardrails, Guardrail
|
|
16
16
|
|
|
17
17
|
from ..guardrails.base import BaseGuardrail
|
|
18
|
+
from ..telemetry import get_telemetry, sanitize_telemetry_config
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _parse_guardrails(guardrails_obj: Guardrails) -> Sequence[BaseGuardrail]:
|
|
@@ -98,9 +99,30 @@ async def lifespan(app: FastAPI):
|
|
|
98
99
|
|
|
99
100
|
await configure_app(app, app.state.engine_config)
|
|
100
101
|
|
|
102
|
+
try:
|
|
103
|
+
telemetry = get_telemetry()
|
|
104
|
+
app.state.telemetry = telemetry
|
|
105
|
+
agent = getattr(app.state, "agent", None)
|
|
106
|
+
telemetry.capture(
|
|
107
|
+
"engine started",
|
|
108
|
+
properties={
|
|
109
|
+
"agent_type": type(agent).__name__ if agent is not None else None,
|
|
110
|
+
"has_agent": agent is not None,
|
|
111
|
+
"engine_config": sanitize_telemetry_config(app.state.engine_config),
|
|
112
|
+
},
|
|
113
|
+
)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"⚠️ Warning: Failed to start telemetry: {e}")
|
|
116
|
+
app.state.telemetry = None
|
|
117
|
+
|
|
101
118
|
yield
|
|
102
119
|
|
|
103
120
|
# Clean up on shutdown
|
|
104
121
|
print("🔄 Idun Agent Engine shutting down...")
|
|
122
|
+
telemetry = getattr(app.state, "telemetry", None)
|
|
123
|
+
if telemetry is not None:
|
|
124
|
+
telemetry.capture("engine stopped")
|
|
105
125
|
await cleanup_agent(app)
|
|
126
|
+
if telemetry is not None:
|
|
127
|
+
telemetry.shutdown()
|
|
106
128
|
print("✅ Agent resources cleaned up successfully.")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Telemetry package for Idun Agent Engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .telemetry import IdunTelemetry, sanitize_telemetry_config
|
|
6
|
+
|
|
7
|
+
_telemetry_singleton: IdunTelemetry | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_telemetry() -> IdunTelemetry:
|
|
11
|
+
"""Return the process-wide telemetry singleton."""
|
|
12
|
+
|
|
13
|
+
global _telemetry_singleton
|
|
14
|
+
if _telemetry_singleton is None:
|
|
15
|
+
_telemetry_singleton = IdunTelemetry()
|
|
16
|
+
return _telemetry_singleton
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["IdunTelemetry", "get_telemetry", "sanitize_telemetry_config"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Telemetry configuration utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
IDUN_TELEMETRY_ENABLED_ENV = "IDUN_TELEMETRY_ENABLED"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def telemetry_enabled(environ: dict[str, str] | None = None) -> bool:
|
|
11
|
+
"""Return whether telemetry is enabled.
|
|
12
|
+
|
|
13
|
+
Telemetry is ON by default. Users can disable it by setting the environment
|
|
14
|
+
variable `IDUN_TELEMETRY_ENABLED` to a falsy value (e.g. "false", "0", "no").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
env = os.environ if environ is None else environ
|
|
18
|
+
raw = env.get(IDUN_TELEMETRY_ENABLED_ENV)
|
|
19
|
+
if raw is None:
|
|
20
|
+
return True
|
|
21
|
+
|
|
22
|
+
value = raw.strip().lower()
|
|
23
|
+
if value in {"0", "false", "no", "off", "disable", "disabled"}:
|
|
24
|
+
return False
|
|
25
|
+
if value in {"1", "true", "yes", "on", "enable", "enabled"}:
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
# Unknown values default to enabled (opt-out).
|
|
29
|
+
return True
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Non-blocking telemetry for Idun Agent Engine.
|
|
2
|
+
|
|
3
|
+
Telemetry is ON by default and can be disabled with `IDUN_TELEMETRY_ENABLED=false`.
|
|
4
|
+
|
|
5
|
+
We persist a stable `distinct_id` in the OS cache directory at:
|
|
6
|
+
`<cache_dir>/idun/telemetry_user_id`
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import platform
|
|
13
|
+
import re
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import uuid
|
|
17
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from platformdirs import user_cache_dir
|
|
23
|
+
|
|
24
|
+
from .config import telemetry_enabled
|
|
25
|
+
|
|
26
|
+
_POSTHOG_HOST = "https://us.i.posthog.com"
|
|
27
|
+
_POSTHOG_PROJECT_API_KEY = "phc_mpAplkH6w5zK1aSkkG0IL5Ys55m6X34BFvGozB2NqPw"
|
|
28
|
+
_DISTINCT_ID_ENV = "IDUN_TELEMETRY_DISTINCT_ID"
|
|
29
|
+
_CACHE_APP_NAME = "idun"
|
|
30
|
+
_CACHE_DISTINCT_ID_FILE = "telemetry_user_id"
|
|
31
|
+
_MAX_VALUE_LENGTH = 200
|
|
32
|
+
_SENSITIVE_KEY_FRAGMENTS = (
|
|
33
|
+
"api_key",
|
|
34
|
+
"apikey",
|
|
35
|
+
"access_key",
|
|
36
|
+
"accesskey",
|
|
37
|
+
"private_key",
|
|
38
|
+
"privatekey",
|
|
39
|
+
"secret",
|
|
40
|
+
"token",
|
|
41
|
+
"password",
|
|
42
|
+
"passphrase",
|
|
43
|
+
"client_secret",
|
|
44
|
+
"clientsecret",
|
|
45
|
+
"bearer",
|
|
46
|
+
"authorization",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
51
|
+
normalized = re.sub(r"[^a-z0-9]+", "_", key.lower())
|
|
52
|
+
return any(fragment in normalized for fragment in _SENSITIVE_KEY_FRAGMENTS)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_private_key_value(value: str) -> bool:
|
|
56
|
+
upper_value = value.upper()
|
|
57
|
+
return "PRIVATE KEY" in upper_value or "BEGIN OPENSSH PRIVATE KEY" in upper_value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _truncate_value(value: str) -> str:
|
|
61
|
+
if len(value) <= _MAX_VALUE_LENGTH:
|
|
62
|
+
return value
|
|
63
|
+
return value[:_MAX_VALUE_LENGTH]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sanitize_telemetry_config(value: Any) -> Any:
|
|
67
|
+
"""Return a telemetry-safe copy of config objects."""
|
|
68
|
+
if hasattr(value, "model_dump") and callable(value.model_dump):
|
|
69
|
+
value = value.model_dump() # type: ignore[assignment]
|
|
70
|
+
elif hasattr(value, "dict") and callable(value.dict):
|
|
71
|
+
value = value.dict() # type: ignore[assignment]
|
|
72
|
+
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
sanitized: dict[str, Any] = {}
|
|
75
|
+
for key, item in value.items():
|
|
76
|
+
if isinstance(key, str) and _is_sensitive_key(key):
|
|
77
|
+
sanitized[key] = "[redacted]"
|
|
78
|
+
else:
|
|
79
|
+
sanitized[key] = sanitize_telemetry_config(item)
|
|
80
|
+
return sanitized
|
|
81
|
+
|
|
82
|
+
if isinstance(value, (list, tuple, set)):
|
|
83
|
+
return [sanitize_telemetry_config(item) for item in value]
|
|
84
|
+
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
if _is_private_key_value(value):
|
|
87
|
+
return "[redacted]"
|
|
88
|
+
return _truncate_value(value)
|
|
89
|
+
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _safe_read_text(path: Path) -> str | None:
|
|
94
|
+
try:
|
|
95
|
+
return path.read_text(encoding="utf-8").strip()
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _safe_write_text(path: Path, text: str) -> None:
|
|
101
|
+
try:
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
path.write_text(text, encoding="utf-8")
|
|
104
|
+
except Exception:
|
|
105
|
+
# Best-effort only.
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _get_or_create_distinct_id() -> str | None:
|
|
110
|
+
"""Return a stable distinct id.
|
|
111
|
+
|
|
112
|
+
Preference order:
|
|
113
|
+
- `IDUN_TELEMETRY_DISTINCT_ID` (if set)
|
|
114
|
+
- A UUID persisted to `<cache_dir>/idun/telemetry_user_id`
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
raw = os.environ.get(_DISTINCT_ID_ENV)
|
|
118
|
+
if raw and raw.strip():
|
|
119
|
+
return raw.strip()
|
|
120
|
+
|
|
121
|
+
cache_dir = Path(user_cache_dir(_CACHE_APP_NAME))
|
|
122
|
+
id_path = cache_dir / _CACHE_DISTINCT_ID_FILE
|
|
123
|
+
|
|
124
|
+
existing = _safe_read_text(id_path)
|
|
125
|
+
if existing:
|
|
126
|
+
return existing
|
|
127
|
+
|
|
128
|
+
new_id = str(uuid.uuid4())
|
|
129
|
+
_safe_write_text(id_path, new_id)
|
|
130
|
+
return new_id
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _common_properties() -> dict[str, Any]:
|
|
134
|
+
from .._version import __version__
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"library": "idun-agent-engine",
|
|
138
|
+
"library_version": __version__,
|
|
139
|
+
"python_version": platform.python_version(),
|
|
140
|
+
"python_implementation": platform.python_implementation(),
|
|
141
|
+
"platform": sys.platform,
|
|
142
|
+
"os": platform.system(),
|
|
143
|
+
"os_version": platform.release(),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(slots=True)
|
|
148
|
+
class IdunTelemetry:
|
|
149
|
+
"""Non-blocking telemetry client."""
|
|
150
|
+
|
|
151
|
+
enabled: bool = field(default_factory=telemetry_enabled)
|
|
152
|
+
_executor: ThreadPoolExecutor | None = field(default=None, init=False, repr=False)
|
|
153
|
+
_client: Any | None = field(default=None, init=False, repr=False)
|
|
154
|
+
_client_lock: threading.Lock = field(
|
|
155
|
+
default_factory=threading.Lock, init=False, repr=False
|
|
156
|
+
)
|
|
157
|
+
_distinct_id: str | None = field(default=None, init=False, repr=False)
|
|
158
|
+
|
|
159
|
+
def _ensure_executor(self) -> ThreadPoolExecutor:
|
|
160
|
+
if self._executor is None:
|
|
161
|
+
self._executor = ThreadPoolExecutor(
|
|
162
|
+
max_workers=1, thread_name_prefix="idun-telemetry"
|
|
163
|
+
)
|
|
164
|
+
return self._executor
|
|
165
|
+
|
|
166
|
+
def _get_client(self) -> Any | None:
|
|
167
|
+
if not self.enabled:
|
|
168
|
+
return None
|
|
169
|
+
if self._client is not None:
|
|
170
|
+
return self._client
|
|
171
|
+
|
|
172
|
+
with self._client_lock:
|
|
173
|
+
if self._client is not None:
|
|
174
|
+
return self._client
|
|
175
|
+
try:
|
|
176
|
+
from posthog import Posthog
|
|
177
|
+
except Exception:
|
|
178
|
+
# Dependency missing or import failure should not break runtime.
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
client = Posthog(_POSTHOG_PROJECT_API_KEY, host=_POSTHOG_HOST)
|
|
182
|
+
self._client = client
|
|
183
|
+
self._distinct_id = _get_or_create_distinct_id()
|
|
184
|
+
return self._client
|
|
185
|
+
|
|
186
|
+
def capture(self, event: str, properties: dict[str, Any] | None = None) -> Future[None] | None:
|
|
187
|
+
"""Capture an event asynchronously (best-effort)."""
|
|
188
|
+
|
|
189
|
+
if not self.enabled:
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
executor = self._ensure_executor()
|
|
193
|
+
|
|
194
|
+
def _send() -> None:
|
|
195
|
+
client = self._get_client()
|
|
196
|
+
if client is None:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
merged: dict[str, Any] = _common_properties()
|
|
200
|
+
if properties:
|
|
201
|
+
merged.update(properties)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
if self._distinct_id:
|
|
205
|
+
client.capture(
|
|
206
|
+
event=event,
|
|
207
|
+
distinct_id=self._distinct_id,
|
|
208
|
+
properties=merged,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
client.capture(event=event, properties=merged)
|
|
212
|
+
except Exception:
|
|
213
|
+
# Never fail user code because of telemetry.
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
return executor.submit(_send)
|
|
218
|
+
except Exception:
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def shutdown(self, timeout_seconds: float = 1.0) -> None:
|
|
222
|
+
"""Best-effort flush/shutdown without blocking application shutdown."""
|
|
223
|
+
|
|
224
|
+
executor = self._executor
|
|
225
|
+
client = self._client
|
|
226
|
+
|
|
227
|
+
if executor is None:
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
def _shutdown_client() -> None:
|
|
231
|
+
try:
|
|
232
|
+
if client is not None:
|
|
233
|
+
shutdown_fn = getattr(client, "shutdown", None)
|
|
234
|
+
if callable(shutdown_fn):
|
|
235
|
+
shutdown_fn()
|
|
236
|
+
except Exception:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
fut = executor.submit(_shutdown_client)
|
|
241
|
+
fut.result(timeout=timeout_seconds)
|
|
242
|
+
except Exception:
|
|
243
|
+
pass
|
|
244
|
+
finally:
|
|
245
|
+
try:
|
|
246
|
+
executor.shutdown(wait=False, cancel_futures=False)
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|