DeepFabric 4.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.
- deepfabric/__init__.py +70 -0
- deepfabric/__main__.py +6 -0
- deepfabric/auth.py +382 -0
- deepfabric/builders.py +303 -0
- deepfabric/builders_agent.py +1304 -0
- deepfabric/cli.py +1288 -0
- deepfabric/config.py +899 -0
- deepfabric/config_manager.py +251 -0
- deepfabric/constants.py +94 -0
- deepfabric/dataset_manager.py +534 -0
- deepfabric/error_codes.py +581 -0
- deepfabric/evaluation/__init__.py +47 -0
- deepfabric/evaluation/backends/__init__.py +32 -0
- deepfabric/evaluation/backends/ollama_backend.py +137 -0
- deepfabric/evaluation/backends/tool_call_parsers.py +409 -0
- deepfabric/evaluation/backends/transformers_backend.py +326 -0
- deepfabric/evaluation/evaluator.py +845 -0
- deepfabric/evaluation/evaluators/__init__.py +13 -0
- deepfabric/evaluation/evaluators/base.py +104 -0
- deepfabric/evaluation/evaluators/builtin/__init__.py +5 -0
- deepfabric/evaluation/evaluators/builtin/tool_calling.py +93 -0
- deepfabric/evaluation/evaluators/registry.py +66 -0
- deepfabric/evaluation/inference.py +155 -0
- deepfabric/evaluation/metrics.py +397 -0
- deepfabric/evaluation/parser.py +304 -0
- deepfabric/evaluation/reporters/__init__.py +13 -0
- deepfabric/evaluation/reporters/base.py +56 -0
- deepfabric/evaluation/reporters/cloud_reporter.py +195 -0
- deepfabric/evaluation/reporters/file_reporter.py +61 -0
- deepfabric/evaluation/reporters/multi_reporter.py +56 -0
- deepfabric/exceptions.py +67 -0
- deepfabric/factory.py +26 -0
- deepfabric/generator.py +1084 -0
- deepfabric/graph.py +545 -0
- deepfabric/hf_hub.py +214 -0
- deepfabric/kaggle_hub.py +219 -0
- deepfabric/llm/__init__.py +41 -0
- deepfabric/llm/api_key_verifier.py +534 -0
- deepfabric/llm/client.py +1206 -0
- deepfabric/llm/errors.py +105 -0
- deepfabric/llm/rate_limit_config.py +262 -0
- deepfabric/llm/rate_limit_detector.py +278 -0
- deepfabric/llm/retry_handler.py +270 -0
- deepfabric/metrics.py +212 -0
- deepfabric/progress.py +262 -0
- deepfabric/prompts.py +290 -0
- deepfabric/schemas.py +1000 -0
- deepfabric/spin/__init__.py +6 -0
- deepfabric/spin/client.py +263 -0
- deepfabric/spin/models.py +26 -0
- deepfabric/stream_simulator.py +90 -0
- deepfabric/tools/__init__.py +5 -0
- deepfabric/tools/defaults.py +85 -0
- deepfabric/tools/loader.py +87 -0
- deepfabric/tools/mcp_client.py +677 -0
- deepfabric/topic_manager.py +303 -0
- deepfabric/topic_model.py +20 -0
- deepfabric/training/__init__.py +35 -0
- deepfabric/training/api_key_prompt.py +302 -0
- deepfabric/training/callback.py +363 -0
- deepfabric/training/metrics_sender.py +301 -0
- deepfabric/tree.py +438 -0
- deepfabric/tui.py +1267 -0
- deepfabric/update_checker.py +166 -0
- deepfabric/utils.py +150 -0
- deepfabric/validation.py +143 -0
- deepfabric-4.4.0.dist-info/METADATA +702 -0
- deepfabric-4.4.0.dist-info/RECORD +71 -0
- deepfabric-4.4.0.dist-info/WHEEL +4 -0
- deepfabric-4.4.0.dist-info/entry_points.txt +2 -0
- deepfabric-4.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Retry handler with intelligent backoff for LLM API calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import random
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable, Coroutine
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from typing import Any, TypeVar
|
|
11
|
+
|
|
12
|
+
from .rate_limit_config import BackoffStrategy, RateLimitConfig
|
|
13
|
+
from .rate_limit_detector import RateLimitDetector
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RetryHandler:
|
|
21
|
+
"""Intelligent retry handler for LLM API calls with provider-aware backoff."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: RateLimitConfig, provider: str):
|
|
24
|
+
"""Initialize retry handler.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: Rate limit configuration
|
|
28
|
+
provider: Provider name (openai, anthropic, gemini, ollama)
|
|
29
|
+
"""
|
|
30
|
+
self.config = config
|
|
31
|
+
self.provider = provider
|
|
32
|
+
self.detector = RateLimitDetector()
|
|
33
|
+
|
|
34
|
+
def should_retry(self, exception: Exception) -> bool:
|
|
35
|
+
"""Determine if an exception should trigger a retry.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
exception: The exception that occurred
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
True if the error is retryable
|
|
42
|
+
"""
|
|
43
|
+
# Check if it's a retryable error (rate limit, timeout, server error)
|
|
44
|
+
if not self.detector.is_retryable_error(exception, self.provider):
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
# If it's a rate limit error, check if we should fail fast
|
|
48
|
+
if self.detector.is_rate_limit_error(exception, self.provider):
|
|
49
|
+
quota_info = self.detector.extract_quota_info(exception, self.provider)
|
|
50
|
+
|
|
51
|
+
# Don't retry if we should fail fast (e.g., daily quota exhausted)
|
|
52
|
+
if self.detector.should_fail_fast(quota_info):
|
|
53
|
+
logger.warning(
|
|
54
|
+
"Failing fast for %s: %s (quota_info: %s)",
|
|
55
|
+
self.provider,
|
|
56
|
+
exception,
|
|
57
|
+
quota_info,
|
|
58
|
+
)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def calculate_delay(
|
|
64
|
+
self,
|
|
65
|
+
attempt: int,
|
|
66
|
+
exception: Exception | None = None,
|
|
67
|
+
) -> float:
|
|
68
|
+
"""Calculate the delay before the next retry attempt.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
attempt: Current attempt number (0-indexed)
|
|
72
|
+
exception: Optional exception to extract retry-after from
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Delay in seconds
|
|
76
|
+
"""
|
|
77
|
+
# Extract retry-after from exception if available
|
|
78
|
+
retry_after = None
|
|
79
|
+
if exception and self.config.respect_retry_after:
|
|
80
|
+
quota_info = self.detector.extract_quota_info(exception, self.provider)
|
|
81
|
+
retry_after = quota_info.retry_after
|
|
82
|
+
|
|
83
|
+
# If provider specifies retry-after, use it (with max_delay cap)
|
|
84
|
+
if retry_after is not None:
|
|
85
|
+
delay = min(retry_after, self.config.max_delay)
|
|
86
|
+
logger.debug("Using retry-after header: %.2fs (capped at %.2fs)", retry_after, delay)
|
|
87
|
+
return delay
|
|
88
|
+
|
|
89
|
+
# Otherwise calculate delay based on backoff strategy
|
|
90
|
+
if self.config.backoff_strategy == BackoffStrategy.EXPONENTIAL:
|
|
91
|
+
delay = self.config.base_delay * (self.config.exponential_base**attempt)
|
|
92
|
+
elif self.config.backoff_strategy == BackoffStrategy.EXPONENTIAL_JITTER:
|
|
93
|
+
base_delay = self.config.base_delay * (self.config.exponential_base**attempt)
|
|
94
|
+
delay = base_delay
|
|
95
|
+
elif self.config.backoff_strategy == BackoffStrategy.LINEAR:
|
|
96
|
+
delay = self.config.base_delay * (attempt + 1)
|
|
97
|
+
elif self.config.backoff_strategy == BackoffStrategy.CONSTANT:
|
|
98
|
+
delay = self.config.base_delay
|
|
99
|
+
else:
|
|
100
|
+
# Default to exponential
|
|
101
|
+
delay = self.config.base_delay * (self.config.exponential_base**attempt)
|
|
102
|
+
|
|
103
|
+
# Apply jitter if enabled
|
|
104
|
+
if self.config.jitter:
|
|
105
|
+
# Add random jitter of ±25% to prevent thundering herd
|
|
106
|
+
jitter_range = delay * 0.25
|
|
107
|
+
delay = delay + random.uniform(-jitter_range, jitter_range) # noqa: S311 # nosec
|
|
108
|
+
|
|
109
|
+
# Ensure delay is within bounds and return
|
|
110
|
+
return max(self.config.base_delay, min(delay, self.config.max_delay))
|
|
111
|
+
|
|
112
|
+
def on_backoff_handler(self, details: dict[str, Any]) -> None:
|
|
113
|
+
"""Callback for backoff retry attempts.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
details: Backoff details including attempt number, wait time, exception
|
|
117
|
+
"""
|
|
118
|
+
exception = details.get("exception")
|
|
119
|
+
wait = details.get("wait", 0)
|
|
120
|
+
tries = details.get("tries", 0)
|
|
121
|
+
|
|
122
|
+
# Extract quota information if it's a rate limit error
|
|
123
|
+
quota_info_str = ""
|
|
124
|
+
if exception and self.detector.is_rate_limit_error(exception, self.provider):
|
|
125
|
+
quota_info = self.detector.extract_quota_info(exception, self.provider)
|
|
126
|
+
if quota_info.quota_type:
|
|
127
|
+
quota_info_str = f" (quota_type: {quota_info.quota_type})"
|
|
128
|
+
|
|
129
|
+
logger.warning(
|
|
130
|
+
"Rate limit/transient error for %s on attempt %d, backing off %.2fs%s: %s",
|
|
131
|
+
self.provider,
|
|
132
|
+
tries,
|
|
133
|
+
wait,
|
|
134
|
+
quota_info_str,
|
|
135
|
+
exception,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def on_giveup_handler(self, details: dict[str, Any]) -> None:
|
|
139
|
+
"""Callback when giving up after max retries.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
details: Backoff details including final exception
|
|
143
|
+
"""
|
|
144
|
+
exception = details.get("exception")
|
|
145
|
+
tries = details.get("tries", 0)
|
|
146
|
+
|
|
147
|
+
logger.error(
|
|
148
|
+
"Giving up after %d attempts for %s: %s",
|
|
149
|
+
tries,
|
|
150
|
+
self.provider,
|
|
151
|
+
exception,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def retry_with_backoff(func: Callable[..., T]) -> Callable[..., T]:
|
|
156
|
+
"""Decorator to add retry logic with backoff to synchronous functions.
|
|
157
|
+
|
|
158
|
+
This decorator is applied to LLMClient methods to handle rate limits
|
|
159
|
+
and transient errors automatically.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
func: Function to wrap with retry logic
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Wrapped function with retry capability
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
@wraps(func)
|
|
169
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> T:
|
|
170
|
+
# Get retry handler from self (assumes LLMClient instance)
|
|
171
|
+
retry_handler = getattr(self, "retry_handler", None)
|
|
172
|
+
if not retry_handler or not isinstance(retry_handler, RetryHandler):
|
|
173
|
+
# No retry handler configured, call function directly
|
|
174
|
+
return func(self, *args, **kwargs)
|
|
175
|
+
|
|
176
|
+
config = retry_handler.config
|
|
177
|
+
attempt = 0
|
|
178
|
+
|
|
179
|
+
while attempt <= config.max_retries:
|
|
180
|
+
try:
|
|
181
|
+
return func(self, *args, **kwargs)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
# Check if we should retry
|
|
184
|
+
if not retry_handler.should_retry(e):
|
|
185
|
+
# Not retryable, raise immediately
|
|
186
|
+
raise
|
|
187
|
+
|
|
188
|
+
# Check if we've exhausted retries
|
|
189
|
+
if attempt >= config.max_retries:
|
|
190
|
+
retry_handler.on_giveup_handler({"exception": e, "tries": attempt + 1})
|
|
191
|
+
raise
|
|
192
|
+
|
|
193
|
+
# Calculate delay and wait
|
|
194
|
+
delay = retry_handler.calculate_delay(attempt, e)
|
|
195
|
+
retry_handler.on_backoff_handler(
|
|
196
|
+
{
|
|
197
|
+
"exception": e,
|
|
198
|
+
"wait": delay,
|
|
199
|
+
"tries": attempt + 1,
|
|
200
|
+
}
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
time.sleep(delay)
|
|
204
|
+
attempt += 1
|
|
205
|
+
|
|
206
|
+
# Should never reach here, but for type safety
|
|
207
|
+
msg = "Unexpected state in retry logic"
|
|
208
|
+
raise RuntimeError(msg)
|
|
209
|
+
|
|
210
|
+
return wrapper
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def retry_with_backoff_async(
|
|
214
|
+
func: Callable[..., Coroutine[Any, Any, T]],
|
|
215
|
+
) -> Callable[..., Coroutine[Any, Any, T]]:
|
|
216
|
+
"""Decorator to add retry logic with backoff to async functions.
|
|
217
|
+
|
|
218
|
+
This decorator is applied to async LLMClient methods to handle rate limits
|
|
219
|
+
and transient errors automatically.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
func: Async function to wrap with retry logic
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Wrapped async function with retry capability
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
@wraps(func)
|
|
229
|
+
async def wrapper(self, *args: Any, **kwargs: Any) -> T:
|
|
230
|
+
# Get retry handler from self (assumes LLMClient instance)
|
|
231
|
+
retry_handler = getattr(self, "retry_handler", None)
|
|
232
|
+
if not retry_handler or not isinstance(retry_handler, RetryHandler):
|
|
233
|
+
# No retry handler configured, call function directly
|
|
234
|
+
return await func(self, *args, **kwargs)
|
|
235
|
+
|
|
236
|
+
config = retry_handler.config
|
|
237
|
+
attempt = 0
|
|
238
|
+
|
|
239
|
+
while attempt <= config.max_retries:
|
|
240
|
+
try:
|
|
241
|
+
return await func(self, *args, **kwargs)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
# Check if we should retry
|
|
244
|
+
if not retry_handler.should_retry(e):
|
|
245
|
+
# Not retryable, raise immediately
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
# Check if we've exhausted retries
|
|
249
|
+
if attempt >= config.max_retries:
|
|
250
|
+
retry_handler.on_giveup_handler({"exception": e, "tries": attempt + 1})
|
|
251
|
+
raise
|
|
252
|
+
|
|
253
|
+
# Calculate delay and wait
|
|
254
|
+
delay = retry_handler.calculate_delay(attempt, e)
|
|
255
|
+
retry_handler.on_backoff_handler(
|
|
256
|
+
{
|
|
257
|
+
"exception": e,
|
|
258
|
+
"wait": delay,
|
|
259
|
+
"tries": attempt + 1,
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
await asyncio.sleep(delay)
|
|
264
|
+
attempt += 1
|
|
265
|
+
|
|
266
|
+
# Should never reach here, but for type safety
|
|
267
|
+
msg = "Unexpected state in retry logic"
|
|
268
|
+
raise RuntimeError(msg)
|
|
269
|
+
|
|
270
|
+
return wrapper
|
deepfabric/metrics.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from posthog import Posthog, identify_context, new_context
|
|
9
|
+
|
|
10
|
+
from .tui import get_tui
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import importlib.metadata
|
|
14
|
+
|
|
15
|
+
VERSION = importlib.metadata.version("deepfabric")
|
|
16
|
+
except (ImportError, importlib.metadata.PackageNotFoundError):
|
|
17
|
+
VERSION = "development"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Initialize PostHog client
|
|
21
|
+
posthog = Posthog(
|
|
22
|
+
project_api_key="phc_Kn8hKQIXHm5OHp5OTxvMvFDUmT7HyOUNlJvWkduB9qO",
|
|
23
|
+
host="https://us.i.posthog.com",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _TelemetryState:
|
|
30
|
+
"""Holds the state for the telemetry module."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self.debug_trace: bool = False
|
|
34
|
+
self.user_id_announced: bool = False
|
|
35
|
+
self.user_id_cache: str | None = None
|
|
36
|
+
self.telemetry_failed_once: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_state = _TelemetryState()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
APP_NAME = "DeepFabric"
|
|
43
|
+
APP_AUTHOR = "DeepFabric"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
from platformdirs import user_data_dir
|
|
48
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
49
|
+
|
|
50
|
+
def user_data_dir(appname: str, appauthor: str | None = None) -> str:
|
|
51
|
+
if os.name == "nt":
|
|
52
|
+
base = os.environ.get("APPDATA") or os.path.expanduser(r"~\AppData\Roaming")
|
|
53
|
+
elif os.name == "posix":
|
|
54
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
|
55
|
+
else:
|
|
56
|
+
base = os.path.expanduser("~")
|
|
57
|
+
return str(Path(base) / (appauthor or appname) / appname)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _user_id_path() -> Path:
|
|
61
|
+
candidates: list[Path] = []
|
|
62
|
+
try:
|
|
63
|
+
candidates.append(Path(user_data_dir(APP_NAME, APP_AUTHOR)))
|
|
64
|
+
except Exception:
|
|
65
|
+
logger.debug("Failed to resolve platform data dir", exc_info=True)
|
|
66
|
+
candidates.append(Path.home() / f".{APP_NAME.lower()}")
|
|
67
|
+
candidates.append(Path.cwd())
|
|
68
|
+
|
|
69
|
+
for data_dir in candidates:
|
|
70
|
+
try:
|
|
71
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
return data_dir / "telemetry_id"
|
|
73
|
+
except Exception:
|
|
74
|
+
logger.debug("Failed to prepare telemetry directory %s", data_dir, exc_info=True)
|
|
75
|
+
|
|
76
|
+
return Path("telemetry_id")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read_user_id(path: Path) -> str | None:
|
|
80
|
+
try:
|
|
81
|
+
if path.exists():
|
|
82
|
+
candidate = path.read_text(encoding="utf-8").strip()
|
|
83
|
+
if candidate:
|
|
84
|
+
uuid.UUID(candidate)
|
|
85
|
+
return candidate
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.debug("Failed to read existing telemetry id", exc_info=True)
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _write_user_id(path: Path) -> str:
|
|
92
|
+
user_id = str(uuid.uuid4())
|
|
93
|
+
tmp_path = path.with_suffix(".tmp")
|
|
94
|
+
try:
|
|
95
|
+
tmp_path.write_text(user_id, encoding="utf-8")
|
|
96
|
+
if os.name == "posix":
|
|
97
|
+
os.chmod(tmp_path, 0o600)
|
|
98
|
+
tmp_path.replace(path)
|
|
99
|
+
except Exception:
|
|
100
|
+
logger.debug("Failed to persist telemetry id", exc_info=True)
|
|
101
|
+
return user_id
|
|
102
|
+
else:
|
|
103
|
+
return user_id
|
|
104
|
+
finally:
|
|
105
|
+
with contextlib.suppress(Exception):
|
|
106
|
+
if tmp_path.exists() and tmp_path != path:
|
|
107
|
+
tmp_path.unlink()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_user_id() -> str:
|
|
111
|
+
"""Generate a stable, anonymous user ID persisted on disk."""
|
|
112
|
+
if _state.user_id_cache is not None:
|
|
113
|
+
return _state.user_id_cache
|
|
114
|
+
|
|
115
|
+
path = _user_id_path()
|
|
116
|
+
user_id = _read_user_id(path)
|
|
117
|
+
if user_id is None:
|
|
118
|
+
user_id = _write_user_id(path)
|
|
119
|
+
|
|
120
|
+
_state.user_id_cache = user_id
|
|
121
|
+
return user_id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_developer() -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Check if this session is marked as a developer session.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
bool: True if DEEPFABRIC_DEVELOPER environment variable is set to 'True'
|
|
130
|
+
"""
|
|
131
|
+
return os.environ.get("DEEPFABRIC_DEVELOPER") == "True"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_trace_debug(enabled: bool) -> None:
|
|
135
|
+
"""Enable or disable debug output for telemetry events."""
|
|
136
|
+
_state.debug_trace = enabled
|
|
137
|
+
if not enabled:
|
|
138
|
+
_state.user_id_announced = False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _announce_user_id(user_id: str) -> None:
|
|
142
|
+
if _state.user_id_announced or not _state.debug_trace:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
get_tui().info(f"metrics user id: {user_id}")
|
|
146
|
+
except Exception: # pragma: no cover - fallback to logging
|
|
147
|
+
logger.debug("metrics user id: %s", user_id)
|
|
148
|
+
|
|
149
|
+
_state.user_id_announced = True
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def trace(event_name, event_properties=None):
|
|
153
|
+
"""
|
|
154
|
+
Send an analytics event if metrics is enabled.
|
|
155
|
+
|
|
156
|
+
Uses privacy-respecting identity tracking with a stable, anonymous user ID
|
|
157
|
+
stored on disk for reuse. Developer sessions are marked with
|
|
158
|
+
the is_developer flag when DEEPFABRIC_DEVELOPER=True.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
event_name: Name of the event to track
|
|
162
|
+
event_properties: Optional dictionary of event properties
|
|
163
|
+
"""
|
|
164
|
+
if not is_enabled():
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Generate stable user ID
|
|
169
|
+
user_id = _get_user_id()
|
|
170
|
+
_announce_user_id(user_id)
|
|
171
|
+
|
|
172
|
+
# Add version and developer flag to all events
|
|
173
|
+
properties = event_properties or {}
|
|
174
|
+
properties["version"] = VERSION
|
|
175
|
+
properties["is_developer"] = _is_developer()
|
|
176
|
+
|
|
177
|
+
# Use identity context to associate events with the user
|
|
178
|
+
with new_context():
|
|
179
|
+
identify_context(user_id)
|
|
180
|
+
posthog.capture(
|
|
181
|
+
distinct_id=user_id,
|
|
182
|
+
event=event_name,
|
|
183
|
+
properties=properties,
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
if not _state.telemetry_failed_once:
|
|
187
|
+
_state.telemetry_failed_once = True
|
|
188
|
+
logger.warning(
|
|
189
|
+
"Failed to send telemetry event. Further failures will be logged at DEBUG level.",
|
|
190
|
+
exc_info=True,
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
logger.debug("Failed to capture metrics event", exc_info=True)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def is_enabled():
|
|
197
|
+
"""Check if analytics is currently enabled."""
|
|
198
|
+
return (
|
|
199
|
+
os.environ.get("ANONYMIZED_TELEMETRY") != "False"
|
|
200
|
+
and os.environ.get("DEEPFABRIC_TESTING") != "True"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def shutdown() -> None:
|
|
205
|
+
"""
|
|
206
|
+
Shutdown the PostHog client, flushing any buffered events.
|
|
207
|
+
|
|
208
|
+
This should be called on application exit to ensure all metrics data is sent.
|
|
209
|
+
"""
|
|
210
|
+
if is_enabled():
|
|
211
|
+
logger.debug("Shutting down metrics client.")
|
|
212
|
+
posthog.shutdown()
|