idun-agent-engine 0.3.9__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.
Files changed (39) hide show
  1. idun_agent_engine/_version.py +1 -1
  2. idun_agent_engine/agent/langgraph/langgraph.py +1 -1
  3. idun_agent_engine/core/app_factory.py +1 -1
  4. idun_agent_engine/core/config_builder.py +5 -6
  5. idun_agent_engine/guardrails/guardrails_hub/__init__.py +2 -2
  6. idun_agent_engine/mcp/__init__.py +18 -2
  7. idun_agent_engine/mcp/helpers.py +95 -45
  8. idun_agent_engine/mcp/registry.py +7 -1
  9. idun_agent_engine/server/lifespan.py +22 -0
  10. idun_agent_engine/telemetry/__init__.py +19 -0
  11. idun_agent_engine/telemetry/config.py +29 -0
  12. idun_agent_engine/telemetry/telemetry.py +248 -0
  13. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.0.dist-info}/METADATA +11 -7
  14. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.0.dist-info}/RECORD +39 -14
  15. idun_platform_cli/groups/init.py +23 -0
  16. idun_platform_cli/main.py +3 -0
  17. idun_platform_cli/tui/__init__.py +0 -0
  18. idun_platform_cli/tui/css/__init__.py +0 -0
  19. idun_platform_cli/tui/css/create_agent.py +789 -0
  20. idun_platform_cli/tui/css/main.py +92 -0
  21. idun_platform_cli/tui/main.py +87 -0
  22. idun_platform_cli/tui/schemas/__init__.py +0 -0
  23. idun_platform_cli/tui/schemas/create_agent.py +60 -0
  24. idun_platform_cli/tui/screens/__init__.py +0 -0
  25. idun_platform_cli/tui/screens/create_agent.py +482 -0
  26. idun_platform_cli/tui/utils/__init__.py +0 -0
  27. idun_platform_cli/tui/utils/config.py +161 -0
  28. idun_platform_cli/tui/validators/__init__.py +0 -0
  29. idun_platform_cli/tui/validators/guardrails.py +76 -0
  30. idun_platform_cli/tui/validators/mcps.py +84 -0
  31. idun_platform_cli/tui/validators/observability.py +65 -0
  32. idun_platform_cli/tui/widgets/__init__.py +15 -0
  33. idun_platform_cli/tui/widgets/guardrails_widget.py +348 -0
  34. idun_platform_cli/tui/widgets/identity_widget.py +234 -0
  35. idun_platform_cli/tui/widgets/mcps_widget.py +230 -0
  36. idun_platform_cli/tui/widgets/observability_widget.py +384 -0
  37. idun_platform_cli/tui/widgets/serve_widget.py +78 -0
  38. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.0.dist-info}/WHEEL +0 -0
  39. {idun_agent_engine-0.3.9.dist-info → idun_agent_engine-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  """Version information for Idun Agent Engine."""
2
2
 
3
- __version__ = "0.3.9"
3
+ __version__ = "0.4.0"
@@ -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.engine.guardrails_v2 import GuardrailsV2 as Guardrails
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,5 @@
1
1
  """Guardrails Hub integration module."""
2
2
 
3
- from .guardrails_hub import GuardrailsHubGuardrail
3
+ from .guardrails_hub import GuardrailsHubGuard
4
4
 
5
- __all__ = ["GuardrailsHubGuardrail"]
5
+ __all__ = ["GuardrailsHubGuard"]
@@ -1,5 +1,21 @@
1
1
  """MCP utilities for Idun Agent Engine."""
2
2
 
3
3
  from .registry import MCPClientRegistry
4
- from .helpers import get_adk_tools_from_api, get_adk_tools_from_file
5
- __all__ = ["MCPClientRegistry", "get_adk_tools_from_api", "get_adk_tools_from_file", "get_adk_tools"]
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
+ ]
@@ -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 _get_toolsets_from_data(config_data: dict[str, Any]) -> list[Any]:
10
- """Internal helper to extract toolsets from config dictionary."""
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
- Args:
34
- config_path: Path to the configuration YAML file.
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
- Returns:
37
- List of initialized ADK McpToolset instances.
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
- # Check if wrapped in engine_config (common pattern in idun)
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 get_adk_tools_from_api() -> list[Any]:
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 get_adk_tools() -> list[Any]:
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
- Args:
91
- agent_api_key: The API key for authentication.
92
- manager_url: The base URL of the Idun Manager (e.g. http://localhost:8000).
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 get_adk_toolsets(self) -> list["McpToolset"]:
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