fruxon 0.8.2__tar.gz → 0.8.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fruxon-0.8.2 → fruxon-0.8.4}/PKG-INFO +1 -1
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/_version.py +2 -2
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/__init__.py +77 -1
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/auth.py +17 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/config.py +28 -1
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/doctor.py +26 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/credentials.py +72 -1
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/fruxon.py +2 -6
- fruxon-0.8.4/src/fruxon/telemetry.py +340 -0
- fruxon-0.8.4/tests/test_telemetry.py +216 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/.gitignore +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/HISTORY.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/LICENSE +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/README.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/pyproject.toml +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/__init__.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/__main__.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/_ssl.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/_schema.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/_shared.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_budget.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_draft.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_revisions.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_tests.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/chat.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/completion.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/describe.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/examples.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/guides.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/integrations.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/keys.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/llm_providers.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/run.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/skills.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/tools.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/trace.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli_auth.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/doctor.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/exceptions.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/models.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/output.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/params.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/__init__.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-agent-mode/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-build-agent/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-create-integration/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-debug-revision/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-meet/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-use-integrations/SKILL.md +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/ui.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/update_check.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/validation.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/__init__.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/conftest.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_actor.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_budgets.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_cli.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_client.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_credentials.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_doctor.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_draft_evaluate_cli.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_drafts.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_fruxon.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_guides.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_output.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_params.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_schema.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_skills.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_ssl.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_test_chats.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_ui.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_update_check.py +0 -0
- {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_validation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fruxon
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.4
|
|
4
4
|
Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
|
|
5
5
|
Project-URL: bugs, https://github.com/fruxon-ai/fruxon-sdk/issues
|
|
6
6
|
Project-URL: changelog, https://github.com/fruxon-ai/fruxon-sdk/blob/main/HISTORY.md
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.8.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 8,
|
|
21
|
+
__version__ = version = '0.8.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 8, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -19,7 +19,7 @@ from typing import Annotated
|
|
|
19
19
|
import typer
|
|
20
20
|
from typer.core import TyperGroup
|
|
21
21
|
|
|
22
|
-
from fruxon import credentials
|
|
22
|
+
from fruxon import credentials, telemetry
|
|
23
23
|
from fruxon.ui import (
|
|
24
24
|
cli_docs_url,
|
|
25
25
|
dashboard_url,
|
|
@@ -32,6 +32,34 @@ from fruxon.ui import (
|
|
|
32
32
|
)
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _classify_exception(exc: BaseException) -> str:
|
|
36
|
+
"""Map an exception to one of the schema's error_class enum values.
|
|
37
|
+
|
|
38
|
+
The wire allowlist (see CliTelemetrySchema.ErrorClasses on the
|
|
39
|
+
backend) deliberately keeps this set small — free-form error text
|
|
40
|
+
could carry user data (paths, agent names, prompts) we don't want
|
|
41
|
+
in the event stream. Mapping at the boundary is where we narrow
|
|
42
|
+
real exceptions to one of those buckets.
|
|
43
|
+
"""
|
|
44
|
+
import socket
|
|
45
|
+
import ssl
|
|
46
|
+
import urllib.error as _ue
|
|
47
|
+
|
|
48
|
+
if isinstance(exc, typer.Exit):
|
|
49
|
+
# ``typer.Exit`` is a normal-flow signal even with a non-zero
|
|
50
|
+
# exit code — it's how we surface user-input errors, not a
|
|
51
|
+
# crash. The actual error class was tagged via the typer call
|
|
52
|
+
# site (we don't see it here). Treat as "user_input" by default.
|
|
53
|
+
return "user_input"
|
|
54
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
55
|
+
return "user_input"
|
|
56
|
+
if isinstance(exc, (_ue.URLError, socket.timeout, ConnectionError, ssl.SSLError, OSError)):
|
|
57
|
+
return "network"
|
|
58
|
+
if isinstance(exc, (PermissionError, FileNotFoundError)):
|
|
59
|
+
return "config"
|
|
60
|
+
return "other"
|
|
61
|
+
|
|
62
|
+
|
|
35
63
|
class _FruxonTyperGroup(TyperGroup):
|
|
36
64
|
"""Branded replacement for Typer's stock ``no such command`` error.
|
|
37
65
|
|
|
@@ -49,6 +77,49 @@ class _FruxonTyperGroup(TyperGroup):
|
|
|
49
77
|
signal.
|
|
50
78
|
"""
|
|
51
79
|
|
|
80
|
+
def invoke(self, ctx):
|
|
81
|
+
"""Wrap subcommand execution with telemetry timing.
|
|
82
|
+
|
|
83
|
+
Telemetry is fire-and-forget on a background thread — see
|
|
84
|
+
:mod:`fruxon.telemetry`. This wrapper exists so the event
|
|
85
|
+
carries (a) which subcommand was actually invoked, (b) whether
|
|
86
|
+
it succeeded, (c) how long it took, and (d) which error class
|
|
87
|
+
any failure mapped to. ``telemetry.track`` itself silently
|
|
88
|
+
no-ops when opted out or unauthenticated, so this code path is
|
|
89
|
+
unconditional.
|
|
90
|
+
"""
|
|
91
|
+
import time as _time
|
|
92
|
+
|
|
93
|
+
command_name = ctx.invoked_subcommand or "(root)"
|
|
94
|
+
started = _time.monotonic()
|
|
95
|
+
succeeded = False
|
|
96
|
+
try:
|
|
97
|
+
result = super().invoke(ctx)
|
|
98
|
+
succeeded = True
|
|
99
|
+
return result
|
|
100
|
+
except BaseException as exc:
|
|
101
|
+
duration_ms = int((_time.monotonic() - started) * 1000)
|
|
102
|
+
telemetry.track(
|
|
103
|
+
"CLI Command Failed",
|
|
104
|
+
{
|
|
105
|
+
"command": command_name,
|
|
106
|
+
"error_class": _classify_exception(exc),
|
|
107
|
+
"duration_ms": duration_ms,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
raise
|
|
111
|
+
finally:
|
|
112
|
+
if succeeded:
|
|
113
|
+
duration_ms = int((_time.monotonic() - started) * 1000)
|
|
114
|
+
telemetry.track(
|
|
115
|
+
"CLI Command Invoked",
|
|
116
|
+
{
|
|
117
|
+
"command": command_name,
|
|
118
|
+
"succeeded": True,
|
|
119
|
+
"duration_ms": duration_ms,
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
52
123
|
def get_command(self, ctx, cmd_name):
|
|
53
124
|
cmd = super().get_command(ctx, cmd_name)
|
|
54
125
|
if cmd is not None:
|
|
@@ -80,6 +151,11 @@ class _FruxonTyperGroup(TyperGroup):
|
|
|
80
151
|
# never raise, so this is the right place.
|
|
81
152
|
atexit.register(maybe_print_update_notice)
|
|
82
153
|
|
|
154
|
+
# Print the one-time telemetry notice at the end of the first invocation
|
|
155
|
+
# (TTY only, never in agent mode, never if already opted out). atexit so
|
|
156
|
+
# it lands after the user's actual command output rather than before it.
|
|
157
|
+
atexit.register(telemetry.maybe_print_first_run_notice)
|
|
158
|
+
|
|
83
159
|
app = typer.Typer(
|
|
84
160
|
help="Fruxon CLI · tools for working with the Fruxon platform.",
|
|
85
161
|
pretty_exceptions_enable=False,
|
|
@@ -126,6 +126,7 @@ def login(
|
|
|
126
126
|
# Resolve the API key. Two paths:
|
|
127
127
|
# * ``--api-key`` supplied → headless, skip browser flow entirely.
|
|
128
128
|
# * No ``--api-key`` → browser flow via :func:`_browser_login`.
|
|
129
|
+
api_key_flag = api_key is not None
|
|
129
130
|
slug_from_grant: str | None = None
|
|
130
131
|
if api_key is None:
|
|
131
132
|
# Browser flow blocks on a human confirming the grant. Surface
|
|
@@ -150,6 +151,11 @@ def login(
|
|
|
150
151
|
api_key=api_key,
|
|
151
152
|
org=org or slug_from_grant or existing.org,
|
|
152
153
|
base_url=base_url or existing.base_url,
|
|
154
|
+
# Preserve the user's telemetry preferences across login —
|
|
155
|
+
# signing in shouldn't silently re-enable opted-out tracking
|
|
156
|
+
# or re-trigger the first-run notice.
|
|
157
|
+
telemetry=existing.telemetry,
|
|
158
|
+
telemetry_notice_shown=existing.telemetry_notice_shown,
|
|
153
159
|
)
|
|
154
160
|
path = credentials.save(new_creds)
|
|
155
161
|
|
|
@@ -167,6 +173,17 @@ def login(
|
|
|
167
173
|
)
|
|
168
174
|
if new_creds.base_url:
|
|
169
175
|
say_ok(f"Base URL [bold]{new_creds.base_url}[/bold]")
|
|
176
|
+
|
|
177
|
+
# Fire after credentials.save so the telemetry POST authenticates
|
|
178
|
+
# against the brand-new key. The method tag distinguishes browser-
|
|
179
|
+
# poll flow from --api-key flag (used for headless / CI setups).
|
|
180
|
+
from fruxon import telemetry as _telemetry
|
|
181
|
+
|
|
182
|
+
_telemetry.track(
|
|
183
|
+
"CLI Login Completed",
|
|
184
|
+
{"method": "api_key_flag" if api_key_flag else "browser"},
|
|
185
|
+
)
|
|
186
|
+
|
|
170
187
|
say_next(
|
|
171
188
|
("fruxon agents list", "browse what's deployed"),
|
|
172
189
|
("fruxon run <agent>", "execute one"),
|
|
@@ -48,7 +48,7 @@ config_app = typer.Typer(
|
|
|
48
48
|
)
|
|
49
49
|
app.add_typer(config_app, name="config")
|
|
50
50
|
|
|
51
|
-
_CONFIG_KEYS = ("api_key", "org", "base_url")
|
|
51
|
+
_CONFIG_KEYS = ("api_key", "org", "base_url", "telemetry")
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
def _ensure_config_key(key: str) -> str:
|
|
@@ -243,10 +243,32 @@ def config_set(
|
|
|
243
243
|
value = stripped
|
|
244
244
|
updates[key] = value
|
|
245
245
|
|
|
246
|
+
# ``telemetry`` is the one non-string-shaped field — accept true/false/
|
|
247
|
+
# 1/0/yes/no on the CLI and persist as a real bool. Anything else is a
|
|
248
|
+
# validation error rather than getting silently stored as a truthy
|
|
249
|
+
# string. (Pop it out of ``updates`` so the success line below still
|
|
250
|
+
# prints the user-visible value, not a normalized "true"/"false".)
|
|
251
|
+
telemetry_update: bool | None = None
|
|
252
|
+
if "telemetry" in updates:
|
|
253
|
+
raw_telemetry = updates["telemetry"]
|
|
254
|
+
norm = raw_telemetry.strip().lower()
|
|
255
|
+
if norm in {"true", "1", "yes", "on"}:
|
|
256
|
+
telemetry_update = True
|
|
257
|
+
elif norm in {"false", "0", "no", "off"}:
|
|
258
|
+
telemetry_update = False
|
|
259
|
+
else:
|
|
260
|
+
fail(
|
|
261
|
+
f"Invalid value for [bold]telemetry[/bold]: {raw_telemetry}",
|
|
262
|
+
hint="Use [bold]true[/bold] or [bold]false[/bold].",
|
|
263
|
+
code=EXIT_VALIDATION,
|
|
264
|
+
)
|
|
265
|
+
|
|
246
266
|
new_creds = StoredCredentials(
|
|
247
267
|
api_key=updates.get("api_key", creds.api_key),
|
|
248
268
|
org=updates.get("org", creds.org),
|
|
249
269
|
base_url=updates.get("base_url", creds.base_url),
|
|
270
|
+
telemetry=telemetry_update if "telemetry" in updates else creds.telemetry,
|
|
271
|
+
telemetry_notice_shown=creds.telemetry_notice_shown,
|
|
250
272
|
)
|
|
251
273
|
credentials.save(new_creds)
|
|
252
274
|
|
|
@@ -291,6 +313,11 @@ def config_unset(
|
|
|
291
313
|
api_key=None if "api_key" in keys else creds.api_key,
|
|
292
314
|
org=None if "org" in keys else creds.org,
|
|
293
315
|
base_url=None if "base_url" in keys else creds.base_url,
|
|
316
|
+
# ``unset telemetry`` reverts to the default (enabled). We don't
|
|
317
|
+
# forget that the user has been told about telemetry, though —
|
|
318
|
+
# the first-run notice should still stay quiet once shown.
|
|
319
|
+
telemetry=None if "telemetry" in keys else creds.telemetry,
|
|
320
|
+
telemetry_notice_shown=creds.telemetry_notice_shown,
|
|
294
321
|
)
|
|
295
322
|
|
|
296
323
|
# If everything is now empty, remove the file entirely instead of
|
|
@@ -88,6 +88,32 @@ def doctor(
|
|
|
88
88
|
results = run_checks(skip_network=skip_network)
|
|
89
89
|
overall = overall_status(results)
|
|
90
90
|
|
|
91
|
+
# Telemetry: send the outcome (and which checks failed, from a small
|
|
92
|
+
# allowlist) so we can spot setup friction patterns across the user
|
|
93
|
+
# base without ever capturing the per-check detail strings.
|
|
94
|
+
from fruxon import telemetry as _telemetry
|
|
95
|
+
|
|
96
|
+
_DOCTOR_TITLE_TO_KEY = {
|
|
97
|
+
"Python interpreter": "interpreter",
|
|
98
|
+
"Fruxon SDK": "sdk_version",
|
|
99
|
+
"Credentials file": "credentials",
|
|
100
|
+
"API key": "credentials",
|
|
101
|
+
"Organization": "credentials",
|
|
102
|
+
"Base URL": "credentials",
|
|
103
|
+
"API reachability": "api_reachability",
|
|
104
|
+
"Auth health": "auth",
|
|
105
|
+
}
|
|
106
|
+
failed_keys = sorted(
|
|
107
|
+
{_DOCTOR_TITLE_TO_KEY[r.title] for r in results if r.status != "ok" and r.title in _DOCTOR_TITLE_TO_KEY}
|
|
108
|
+
)
|
|
109
|
+
_telemetry.track(
|
|
110
|
+
"CLI Doctor Run",
|
|
111
|
+
{
|
|
112
|
+
"result": "all_pass" if overall == "ok" else "some_failed",
|
|
113
|
+
"failed_checks": failed_keys,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
91
117
|
if output == "json":
|
|
92
118
|
payload = {
|
|
93
119
|
"overall": overall,
|
|
@@ -72,6 +72,16 @@ class StoredCredentials:
|
|
|
72
72
|
api_key: str | None = None
|
|
73
73
|
org: str | None = None
|
|
74
74
|
base_url: str | None = None
|
|
75
|
+
# CLI telemetry preference. ``None`` = default (enabled), ``False`` =
|
|
76
|
+
# explicit opt-out via ``fruxon config set telemetry false`` or the
|
|
77
|
+
# one-time question on first run. We track the tri-state so a future
|
|
78
|
+
# change of default never quietly overrides a user's explicit choice.
|
|
79
|
+
telemetry: bool | None = None
|
|
80
|
+
# Whether we've ever printed the first-run telemetry notice on this
|
|
81
|
+
# machine. Stored here (not as a separate flag file) so it's covered
|
|
82
|
+
# by the same atomic write + ``FRUXON_CONFIG_DIR`` override as
|
|
83
|
+
# everything else.
|
|
84
|
+
telemetry_notice_shown: bool = False
|
|
75
85
|
|
|
76
86
|
|
|
77
87
|
def config_dir() -> Path:
|
|
@@ -127,6 +137,8 @@ def load() -> StoredCredentials:
|
|
|
127
137
|
api_key=api_key,
|
|
128
138
|
org=_str_or_none(file_data.get("org")),
|
|
129
139
|
base_url=_str_or_none(file_data.get("base_url")),
|
|
140
|
+
telemetry=_bool_or_none(file_data.get("telemetry")),
|
|
141
|
+
telemetry_notice_shown=bool(file_data.get("telemetry_notice_shown") or False),
|
|
130
142
|
)
|
|
131
143
|
_cache = (creds, source)
|
|
132
144
|
return creds
|
|
@@ -171,11 +183,18 @@ def save(creds: StoredCredentials) -> Path:
|
|
|
171
183
|
# Try keyring first. On success, the file payload omits the secret
|
|
172
184
|
# entirely — no copies of the key anywhere on disk. On failure, the
|
|
173
185
|
# file holds it as the only persistent store.
|
|
174
|
-
payload: dict[str,
|
|
186
|
+
payload: dict[str, object] = {}
|
|
175
187
|
if creds.org:
|
|
176
188
|
payload["org"] = creds.org
|
|
177
189
|
if creds.base_url:
|
|
178
190
|
payload["base_url"] = creds.base_url
|
|
191
|
+
if creds.telemetry is not None:
|
|
192
|
+
# Only write the key when explicitly set so the absence of the
|
|
193
|
+
# field continues to mean "default" — letting us change the
|
|
194
|
+
# default later without invalidating users' on-disk state.
|
|
195
|
+
payload["telemetry"] = bool(creds.telemetry)
|
|
196
|
+
if creds.telemetry_notice_shown:
|
|
197
|
+
payload["telemetry_notice_shown"] = True
|
|
179
198
|
|
|
180
199
|
keyring_ok = False
|
|
181
200
|
if creds.api_key:
|
|
@@ -264,6 +283,58 @@ def _str_or_none(value: object) -> str | None:
|
|
|
264
283
|
return None
|
|
265
284
|
|
|
266
285
|
|
|
286
|
+
def _bool_or_none(value: object) -> bool | None:
|
|
287
|
+
"""Parse a stored JSON value to a tri-state bool / None.
|
|
288
|
+
|
|
289
|
+
JSON has no "missing" sentinel for the value space we care about, so
|
|
290
|
+
we keep ``None`` when the key is absent (= default behavior) and only
|
|
291
|
+
return ``True`` / ``False`` for explicit values. Strings ``"true"`` /
|
|
292
|
+
``"false"`` are accepted defensively for users who hand-edit the file.
|
|
293
|
+
"""
|
|
294
|
+
if isinstance(value, bool):
|
|
295
|
+
return value
|
|
296
|
+
if isinstance(value, str):
|
|
297
|
+
s = value.strip().lower()
|
|
298
|
+
if s in {"true", "1", "yes"}:
|
|
299
|
+
return True
|
|
300
|
+
if s in {"false", "0", "no"}:
|
|
301
|
+
return False
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def set_telemetry(enabled: bool) -> Path:
|
|
306
|
+
"""Persist the user's telemetry on/off choice.
|
|
307
|
+
|
|
308
|
+
Convenience helper so callers (``fruxon config set telemetry …`` and
|
|
309
|
+
any future setup wizard) don't need to round-trip through
|
|
310
|
+
:func:`load` + :func:`save` themselves.
|
|
311
|
+
"""
|
|
312
|
+
current = load()
|
|
313
|
+
return save(
|
|
314
|
+
StoredCredentials(
|
|
315
|
+
api_key=current.api_key,
|
|
316
|
+
org=current.org,
|
|
317
|
+
base_url=current.base_url,
|
|
318
|
+
telemetry=enabled,
|
|
319
|
+
telemetry_notice_shown=current.telemetry_notice_shown,
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def mark_telemetry_notice_shown() -> Path:
|
|
325
|
+
"""Flip the first-run notice flag so we don't print it again."""
|
|
326
|
+
current = load()
|
|
327
|
+
return save(
|
|
328
|
+
StoredCredentials(
|
|
329
|
+
api_key=current.api_key,
|
|
330
|
+
org=current.org,
|
|
331
|
+
base_url=current.base_url,
|
|
332
|
+
telemetry=current.telemetry,
|
|
333
|
+
telemetry_notice_shown=True,
|
|
334
|
+
)
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
267
338
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
268
339
|
# File and keyring backends
|
|
269
340
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -935,15 +935,11 @@ class FruxonClient:
|
|
|
935
935
|
that share the same envelope (integrations, tools, …) don't each
|
|
936
936
|
reimplement the unwrap.
|
|
937
937
|
|
|
938
|
-
|
|
939
|
-
``page_size`` in snake_case; camelCase variants are silently
|
|
940
|
-
ignored, which manifests as the server echoing the same token
|
|
941
|
-
back forever. Sending the snake_case name is the actual fix; the
|
|
942
|
-
cycle-detection in callers is now just defense-in-depth.
|
|
938
|
+
Query param is ``pageToken`` (camelCase per AIP-127).
|
|
943
939
|
"""
|
|
944
940
|
params = list(base_params)
|
|
945
941
|
if page_token:
|
|
946
|
-
params.append(("
|
|
942
|
+
params.append(("pageToken", page_token))
|
|
947
943
|
url = base_url + ("?" + urllib.parse.urlencode(params) if params else "")
|
|
948
944
|
raw = self._get_json(url)
|
|
949
945
|
if isinstance(raw, dict):
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Anonymous CLI usage telemetry.
|
|
2
|
+
|
|
3
|
+
The CLI POSTs a single event per invocation to ``{base_url}/v1/cli-events``
|
|
4
|
+
on a background thread. The backend validates each event against an
|
|
5
|
+
allowlist and forwards to Mixpanel — see
|
|
6
|
+
``fruxon-backend/Server/Controllers/CliTelemetryController.cs`` and
|
|
7
|
+
``CliTelemetrySchema.cs``.
|
|
8
|
+
|
|
9
|
+
What we track:
|
|
10
|
+
* Which command was invoked, and whether it succeeded.
|
|
11
|
+
* Local-only commands (``describe``, ``examples``, ``guides``).
|
|
12
|
+
* Diagnostic outcomes (``doctor``, version updates, login).
|
|
13
|
+
* CLI version, Python version, OS, and whether an AI driver is
|
|
14
|
+
running the show (``human`` vs ``ai_agent``).
|
|
15
|
+
|
|
16
|
+
What we never send:
|
|
17
|
+
Command arguments, output, file paths, hostnames, usernames,
|
|
18
|
+
exception messages, doctor output text, the API key. See
|
|
19
|
+
https://fruxon.com/privacy-policy for the full list.
|
|
20
|
+
|
|
21
|
+
Identity:
|
|
22
|
+
Telemetry only fires when ``fruxon login`` has succeeded. The
|
|
23
|
+
backend derives the Mixpanel ``distinct_id`` (user email) and
|
|
24
|
+
``$groups: {org: slug}`` from the API key; the CLI never sends
|
|
25
|
+
those fields. Anonymous (pre-login) events are deliberately not
|
|
26
|
+
collected.
|
|
27
|
+
|
|
28
|
+
Opt-out (resolution order, first wins):
|
|
29
|
+
1. ``FRUXON_NO_TELEMETRY=1`` — env var
|
|
30
|
+
2. ``DO_NOT_TRACK=1`` — env var (community convention)
|
|
31
|
+
3. ``fruxon config set telemetry false`` — persisted in
|
|
32
|
+
``~/.fruxon/credentials``
|
|
33
|
+
4. Default: enabled.
|
|
34
|
+
|
|
35
|
+
Design notes:
|
|
36
|
+
* Pure stdlib (``urllib.request``). The SDK already uses urllib for
|
|
37
|
+
``cli_auth.py`` — no new dependency.
|
|
38
|
+
* Background thread with a small daemon queue. The CLI never waits.
|
|
39
|
+
* Failures are silently logged via the standard ``logging`` module;
|
|
40
|
+
telemetry must never affect the exit code, latency, or stderr.
|
|
41
|
+
* Built-in disable when the queue is bounded and full — if the
|
|
42
|
+
backend is slow we drop events rather than blocking shutdown.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import os
|
|
50
|
+
import platform
|
|
51
|
+
import queue
|
|
52
|
+
import ssl
|
|
53
|
+
import sys
|
|
54
|
+
import threading
|
|
55
|
+
import urllib.error
|
|
56
|
+
import urllib.request
|
|
57
|
+
from typing import Any
|
|
58
|
+
|
|
59
|
+
from . import credentials
|
|
60
|
+
from ._ssl import context_for as _ssl_context_for
|
|
61
|
+
from .ui import get_version
|
|
62
|
+
|
|
63
|
+
logger = logging.getLogger("fruxon.telemetry")
|
|
64
|
+
|
|
65
|
+
# Cap how many events can pile up before we start dropping. A bounded
|
|
66
|
+
# queue keeps a slow / unreachable backend from causing the CLI to hang
|
|
67
|
+
# during shutdown.
|
|
68
|
+
_QUEUE_CAPACITY = 64
|
|
69
|
+
|
|
70
|
+
# How long the background thread waits at exit for in-flight POSTs.
|
|
71
|
+
# Short on purpose: telemetry must never delay the user's prompt return.
|
|
72
|
+
_SHUTDOWN_WAIT_SECONDS = 0.5
|
|
73
|
+
|
|
74
|
+
# Per-request HTTP timeout. Short and decisive — a slow Mixpanel is
|
|
75
|
+
# something we'd rather drop and move on from.
|
|
76
|
+
_HTTP_TIMEOUT_SECONDS = 2.0
|
|
77
|
+
|
|
78
|
+
# Default backend ingest URL. Overridable via ``FRUXON_BASE_URL`` (the
|
|
79
|
+
# same env that overrides every other API call), which is what we want
|
|
80
|
+
# for staging / self-hosted deployments.
|
|
81
|
+
_DEFAULT_BASE_URL = "https://api.fruxon.com"
|
|
82
|
+
_EVENT_PATH = "/v1/cli-events"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _detect_driver() -> str:
|
|
86
|
+
"""Tag the event with whether a human or an AI agent is driving.
|
|
87
|
+
|
|
88
|
+
Mirrors :func:`fruxon.ui.is_agent_mode` — Claude Code (``CLAUDECODE=1``)
|
|
89
|
+
and CI runners (``CI=1``) are treated as "non-human" along with the
|
|
90
|
+
explicit ``FRUXON_AGENT_MODE=1``. This is the one signal that's hard
|
|
91
|
+
to get from any other telemetry pipeline, and it's what makes
|
|
92
|
+
human-vs-agent usage analyses possible in Mixpanel.
|
|
93
|
+
"""
|
|
94
|
+
for var in ("FRUXON_AGENT_MODE", "CLAUDECODE"):
|
|
95
|
+
if (os.environ.get(var) or "").strip().lower() in {"1", "true", "yes"}:
|
|
96
|
+
return "ai_agent"
|
|
97
|
+
ci = (os.environ.get("CI") or "").strip().lower()
|
|
98
|
+
if ci in {"1", "true", "yes"}:
|
|
99
|
+
return "ai_agent"
|
|
100
|
+
return "human"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _base_properties() -> dict[str, Any]:
|
|
104
|
+
"""Properties attached to every event (the schema's universal set).
|
|
105
|
+
|
|
106
|
+
These describe the local environment — version, OS, driver mode.
|
|
107
|
+
Everything else is per-event.
|
|
108
|
+
"""
|
|
109
|
+
return {
|
|
110
|
+
"cli_version": get_version(),
|
|
111
|
+
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
|
|
112
|
+
"os": platform.system().lower() or "unknown",
|
|
113
|
+
"driver": _detect_driver(),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_disabled_via_env() -> bool:
|
|
118
|
+
"""Either of the documented env vars hard-disables telemetry.
|
|
119
|
+
|
|
120
|
+
Honors ``DO_NOT_TRACK`` (a community convention some users wire into
|
|
121
|
+
their shell rc) alongside the project-specific ``FRUXON_NO_TELEMETRY``.
|
|
122
|
+
"""
|
|
123
|
+
for var in ("FRUXON_NO_TELEMETRY", "DO_NOT_TRACK"):
|
|
124
|
+
if (os.environ.get(var) or "").strip().lower() in {"1", "true", "yes"}:
|
|
125
|
+
return True
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_enabled() -> bool:
|
|
130
|
+
"""Resolve the final on/off decision for the current invocation.
|
|
131
|
+
|
|
132
|
+
Order: env-var off > config off > default on. We don't check for the
|
|
133
|
+
API key here — :class:`TelemetryClient` does that just-in-time so
|
|
134
|
+
``is_enabled`` stays cheap (a couple of env lookups and a config
|
|
135
|
+
read) and can be called from tests without network reach.
|
|
136
|
+
"""
|
|
137
|
+
if _is_disabled_via_env():
|
|
138
|
+
return False
|
|
139
|
+
pref = credentials.load().telemetry
|
|
140
|
+
if pref is False:
|
|
141
|
+
return False
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TelemetryClient:
|
|
146
|
+
"""Background worker that POSTs CLI events to the backend.
|
|
147
|
+
|
|
148
|
+
One instance per process, created lazily by :func:`get_client`. The
|
|
149
|
+
worker thread is daemonized so the interpreter can exit cleanly even
|
|
150
|
+
if Mixpanel is unreachable.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def __init__(self) -> None:
|
|
154
|
+
self._queue: queue.Queue[dict[str, Any] | None] = queue.Queue(maxsize=_QUEUE_CAPACITY)
|
|
155
|
+
self._thread: threading.Thread | None = None
|
|
156
|
+
self._lock = threading.Lock()
|
|
157
|
+
self._dropped = 0
|
|
158
|
+
|
|
159
|
+
def track(self, event_name: str, properties: dict[str, Any] | None = None) -> None:
|
|
160
|
+
"""Schedule an event for background submission.
|
|
161
|
+
|
|
162
|
+
Always safe to call — opt-out and auth checks happen here so
|
|
163
|
+
callers don't need to guard. The actual HTTP work happens on a
|
|
164
|
+
daemon thread; the call returns immediately.
|
|
165
|
+
"""
|
|
166
|
+
if not is_enabled():
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
# We only send post-login events. Anonymous pre-auth tracking
|
|
170
|
+
# would require backend allowlisting of unauthenticated calls
|
|
171
|
+
# plus a machine UUID to alias — deliberately deferred.
|
|
172
|
+
api_key = credentials.resolve_api_key(None)
|
|
173
|
+
if not api_key:
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
base_url = (credentials.resolve_base_url(None) or _DEFAULT_BASE_URL).rstrip("/")
|
|
177
|
+
|
|
178
|
+
payload = {
|
|
179
|
+
"eventName": event_name,
|
|
180
|
+
"properties": {**_base_properties(), **(properties or {})},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
envelope = {"url": base_url + _EVENT_PATH, "api_key": api_key, "payload": payload}
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
self._queue.put_nowait(envelope)
|
|
187
|
+
except queue.Full:
|
|
188
|
+
# Drop and move on. Bounded queue is the point — a slow or
|
|
189
|
+
# unreachable backend can't grow memory or block shutdown.
|
|
190
|
+
self._dropped += 1
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
self._ensure_worker_started()
|
|
194
|
+
|
|
195
|
+
def shutdown(self, *, wait: float = _SHUTDOWN_WAIT_SECONDS) -> None:
|
|
196
|
+
"""Signal the worker to finish, give it up to ``wait`` seconds.
|
|
197
|
+
|
|
198
|
+
Called from an :mod:`atexit` hook; do not call directly. The
|
|
199
|
+
wait is intentionally short — we'd rather drop the tail than
|
|
200
|
+
hang the user's prompt by even a second.
|
|
201
|
+
"""
|
|
202
|
+
if self._thread is None or not self._thread.is_alive():
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
self._queue.put_nowait(None)
|
|
206
|
+
except queue.Full:
|
|
207
|
+
# The queue is already full — the worker is busy or stuck;
|
|
208
|
+
# there's nothing useful to do but let the daemon thread die
|
|
209
|
+
# with the interpreter.
|
|
210
|
+
return
|
|
211
|
+
self._thread.join(timeout=wait)
|
|
212
|
+
|
|
213
|
+
def _ensure_worker_started(self) -> None:
|
|
214
|
+
with self._lock:
|
|
215
|
+
if self._thread is not None and self._thread.is_alive():
|
|
216
|
+
return
|
|
217
|
+
self._thread = threading.Thread(target=self._run, name="fruxon-telemetry", daemon=True)
|
|
218
|
+
self._thread.start()
|
|
219
|
+
|
|
220
|
+
def _run(self) -> None:
|
|
221
|
+
while True:
|
|
222
|
+
try:
|
|
223
|
+
item = self._queue.get(timeout=1.0)
|
|
224
|
+
except queue.Empty:
|
|
225
|
+
# No work pending. Exit so the daemon doesn't stay
|
|
226
|
+
# parked for the lifetime of long-running commands
|
|
227
|
+
# (``fruxon chat`` can stay open for hours). It restarts
|
|
228
|
+
# on the next track() call.
|
|
229
|
+
return
|
|
230
|
+
if item is None:
|
|
231
|
+
return
|
|
232
|
+
try:
|
|
233
|
+
self._post(item)
|
|
234
|
+
except Exception: # noqa: BLE001 — never let telemetry crash
|
|
235
|
+
logger.debug("CLI telemetry POST failed", exc_info=True)
|
|
236
|
+
finally:
|
|
237
|
+
self._queue.task_done()
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _post(envelope: dict[str, Any]) -> None:
|
|
241
|
+
url = envelope["url"]
|
|
242
|
+
body = json.dumps(envelope["payload"]).encode("utf-8")
|
|
243
|
+
req = urllib.request.Request(
|
|
244
|
+
url,
|
|
245
|
+
data=body,
|
|
246
|
+
method="POST",
|
|
247
|
+
headers={
|
|
248
|
+
"Content-Type": "application/json",
|
|
249
|
+
"Authorization": f"Bearer {envelope['api_key']}",
|
|
250
|
+
"User-Agent": f"fruxon-cli/{get_version()}",
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
context: ssl.SSLContext | None = _ssl_context_for(url)
|
|
254
|
+
try:
|
|
255
|
+
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT_SECONDS, context=context):
|
|
256
|
+
# We don't care about the response body; the backend
|
|
257
|
+
# returns 202 Accepted for any valid event and ignores
|
|
258
|
+
# the rest. Read implicitly closes the connection.
|
|
259
|
+
pass
|
|
260
|
+
except urllib.error.HTTPError as exc:
|
|
261
|
+
# 4xx ≠ programming bug worth surfacing here. The backend
|
|
262
|
+
# logs unknown event names; nothing the user can do.
|
|
263
|
+
logger.debug("telemetry HTTP %s for %s", exc.code, url)
|
|
264
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
265
|
+
# Offline laptop, DNS hiccup, captive portal, sleeping
|
|
266
|
+
# machine — none of these should bother the user.
|
|
267
|
+
logger.debug("telemetry network error: %s", exc)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
_client: TelemetryClient | None = None
|
|
271
|
+
_client_lock = threading.Lock()
|
|
272
|
+
_atexit_registered = False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def get_client() -> TelemetryClient:
|
|
276
|
+
"""Lazily create the process-wide :class:`TelemetryClient`.
|
|
277
|
+
|
|
278
|
+
Lazy so importing this module from tests has no cost. The
|
|
279
|
+
:func:`atexit` registration also runs once, here.
|
|
280
|
+
"""
|
|
281
|
+
global _client, _atexit_registered
|
|
282
|
+
if _client is not None:
|
|
283
|
+
return _client
|
|
284
|
+
with _client_lock:
|
|
285
|
+
if _client is None:
|
|
286
|
+
_client = TelemetryClient()
|
|
287
|
+
if not _atexit_registered:
|
|
288
|
+
import atexit
|
|
289
|
+
|
|
290
|
+
atexit.register(_client.shutdown)
|
|
291
|
+
_atexit_registered = True
|
|
292
|
+
return _client
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# Convenience: most call sites only want a one-liner.
|
|
296
|
+
def track(event_name: str, properties: dict[str, Any] | None = None) -> None:
|
|
297
|
+
"""Module-level wrapper so callers can ``from .telemetry import track``."""
|
|
298
|
+
get_client().track(event_name, properties)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# -----------------------------------------------------------------------------
|
|
302
|
+
# First-run notice
|
|
303
|
+
# -----------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
_NOTICE = (
|
|
306
|
+
"ℹ️ Fruxon CLI collects usage data to improve the tool.\n"
|
|
307
|
+
" See: https://fruxon.com/privacy-policy\n"
|
|
308
|
+
" Disable: fruxon config set telemetry false (or DO_NOT_TRACK=1)\n"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def maybe_print_first_run_notice() -> None:
|
|
313
|
+
"""Print the telemetry notice once per machine, on a TTY.
|
|
314
|
+
|
|
315
|
+
Gated by a flag in the credentials file so we only ever say it
|
|
316
|
+
once. Piped / non-interactive runs (CI, scripts, AI agents) never
|
|
317
|
+
see it — they're not going to read it and it would only show up
|
|
318
|
+
as unexpected text on stderr.
|
|
319
|
+
"""
|
|
320
|
+
if not sys.stderr.isatty():
|
|
321
|
+
return
|
|
322
|
+
if _is_disabled_via_env():
|
|
323
|
+
return
|
|
324
|
+
creds = credentials.load()
|
|
325
|
+
if creds.telemetry_notice_shown:
|
|
326
|
+
return
|
|
327
|
+
if creds.telemetry is False:
|
|
328
|
+
# User has already opted out — no reason to nag.
|
|
329
|
+
return
|
|
330
|
+
|
|
331
|
+
sys.stderr.write("\n" + _NOTICE + "\n")
|
|
332
|
+
sys.stderr.flush()
|
|
333
|
+
|
|
334
|
+
# Persist the "shown" flag so we don't re-print on the next command.
|
|
335
|
+
try:
|
|
336
|
+
credentials.mark_telemetry_notice_shown()
|
|
337
|
+
except Exception: # noqa: BLE001
|
|
338
|
+
# If we can't write the credentials file, falling through to a
|
|
339
|
+
# second print on the next command is still cheap and harmless.
|
|
340
|
+
logger.debug("failed to persist telemetry notice flag", exc_info=True)
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Telemetry unit tests.
|
|
2
|
+
|
|
3
|
+
These exercise the resolution rules (opt-out, auth gating, env vars) and
|
|
4
|
+
the bookkeeping around the first-run notice. The actual HTTP POST is
|
|
5
|
+
mocked so the tests don't reach Mixpanel — that path is tested separately
|
|
6
|
+
via the backend's controller tests.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from unittest import mock
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from fruxon import credentials, telemetry
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture(autouse=True)
|
|
21
|
+
def _isolate_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
22
|
+
"""Point the CLI's config directory at a per-test tmpdir.
|
|
23
|
+
|
|
24
|
+
Without this, the tests would clobber the developer's real
|
|
25
|
+
``~/.fruxon/credentials`` file every run. Also clears the in-process
|
|
26
|
+
credentials cache between tests since it survives function scope.
|
|
27
|
+
"""
|
|
28
|
+
monkeypatch.setenv("FRUXON_CONFIG_DIR", str(tmp_path))
|
|
29
|
+
# Strip any developer-leaking env vars that would skew resolution.
|
|
30
|
+
for var in (
|
|
31
|
+
"FRUXON_NO_TELEMETRY",
|
|
32
|
+
"DO_NOT_TRACK",
|
|
33
|
+
"FRUXON_API_KEY",
|
|
34
|
+
"FRUXON_AGENT_MODE",
|
|
35
|
+
"CLAUDECODE",
|
|
36
|
+
"CI",
|
|
37
|
+
):
|
|
38
|
+
monkeypatch.delenv(var, raising=False)
|
|
39
|
+
credentials._invalidate_cache()
|
|
40
|
+
return tmp_path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture()
|
|
44
|
+
def _patched_client(monkeypatch: pytest.MonkeyPatch):
|
|
45
|
+
"""Replace the lazy global with a fresh, mockable instance per test.
|
|
46
|
+
|
|
47
|
+
Reaching into module state isn't pretty but it's the only way to
|
|
48
|
+
keep tests independent of each other given the ``atexit``
|
|
49
|
+
side-effect on first :func:`telemetry.get_client` call.
|
|
50
|
+
"""
|
|
51
|
+
monkeypatch.setattr(telemetry, "_client", None, raising=False)
|
|
52
|
+
return telemetry
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _write_creds(*, api_key: str | None = None, telemetry_pref: bool | None = None) -> None:
|
|
56
|
+
"""Persist a credentials snapshot via the public API so the test
|
|
57
|
+
exercises the same code path the real CLI does."""
|
|
58
|
+
credentials.save(
|
|
59
|
+
credentials.StoredCredentials(
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
telemetry=telemetry_pref,
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
credentials._invalidate_cache()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# -----------------------------------------------------------------------------
|
|
68
|
+
# is_enabled
|
|
69
|
+
# -----------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_is_enabled_default_is_true_when_no_signal() -> None:
|
|
73
|
+
"""No env var, no config → telemetry on. That's the documented default."""
|
|
74
|
+
assert telemetry.is_enabled() is True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_is_enabled_respects_fruxon_no_telemetry_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
78
|
+
monkeypatch.setenv("FRUXON_NO_TELEMETRY", "1")
|
|
79
|
+
assert telemetry.is_enabled() is False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_is_enabled_respects_do_not_track_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
83
|
+
"""The community-standard DO_NOT_TRACK convention is honored."""
|
|
84
|
+
monkeypatch.setenv("DO_NOT_TRACK", "1")
|
|
85
|
+
assert telemetry.is_enabled() is False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_is_enabled_respects_config_false() -> None:
|
|
89
|
+
"""``fruxon config set telemetry false`` persists to the file."""
|
|
90
|
+
_write_creds(telemetry_pref=False)
|
|
91
|
+
assert telemetry.is_enabled() is False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_is_enabled_env_overrides_config_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
95
|
+
"""If the user opts out via env, even an explicit ``true`` config doesn't override."""
|
|
96
|
+
_write_creds(telemetry_pref=True)
|
|
97
|
+
monkeypatch.setenv("FRUXON_NO_TELEMETRY", "1")
|
|
98
|
+
assert telemetry.is_enabled() is False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# -----------------------------------------------------------------------------
|
|
102
|
+
# track() — auth and opt-out gating
|
|
103
|
+
# -----------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_track_noop_when_disabled(_patched_client: telemetry, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
107
|
+
"""Opted-out users never even enqueue the event."""
|
|
108
|
+
_write_creds(telemetry_pref=False, api_key="fxn_test")
|
|
109
|
+
client = _patched_client.get_client()
|
|
110
|
+
with mock.patch.object(client, "_ensure_worker_started") as worker:
|
|
111
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
112
|
+
worker.assert_not_called()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_track_noop_when_unauthenticated(_patched_client: telemetry) -> None:
|
|
116
|
+
"""No API key → no telemetry. Anonymous tracking was a deliberate
|
|
117
|
+
non-goal — pre-login events don't go to Mixpanel."""
|
|
118
|
+
client = _patched_client.get_client()
|
|
119
|
+
with mock.patch.object(client, "_ensure_worker_started") as worker:
|
|
120
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
121
|
+
worker.assert_not_called()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_track_enqueues_when_authenticated(_patched_client: telemetry) -> None:
|
|
125
|
+
"""Happy path: authenticated + enabled → event hits the queue."""
|
|
126
|
+
_write_creds(api_key="fxn_test")
|
|
127
|
+
client = _patched_client.get_client()
|
|
128
|
+
with mock.patch.object(client, "_ensure_worker_started") as worker:
|
|
129
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
130
|
+
worker.assert_called_once()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_track_attaches_universal_properties(_patched_client: telemetry) -> None:
|
|
134
|
+
"""Every event carries cli_version, python_version, os, driver."""
|
|
135
|
+
_write_creds(api_key="fxn_test")
|
|
136
|
+
client = _patched_client.get_client()
|
|
137
|
+
|
|
138
|
+
captured: list[dict] = []
|
|
139
|
+
with mock.patch.object(client, "_ensure_worker_started"):
|
|
140
|
+
# Drain whatever the queue captures so we can inspect it.
|
|
141
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
142
|
+
captured.append(client._queue.get_nowait())
|
|
143
|
+
|
|
144
|
+
envelope = captured[0]
|
|
145
|
+
props = envelope["payload"]["properties"]
|
|
146
|
+
assert props["command"] == "doctor"
|
|
147
|
+
assert "cli_version" in props
|
|
148
|
+
assert "python_version" in props
|
|
149
|
+
assert "os" in props
|
|
150
|
+
assert props["driver"] in {"human", "ai_agent"}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_track_tags_ai_driver(_patched_client: telemetry, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
154
|
+
"""CLAUDECODE / FRUXON_AGENT_MODE / CI flip driver to ai_agent."""
|
|
155
|
+
_write_creds(api_key="fxn_test")
|
|
156
|
+
monkeypatch.setenv("CLAUDECODE", "1")
|
|
157
|
+
client = _patched_client.get_client()
|
|
158
|
+
with mock.patch.object(client, "_ensure_worker_started"):
|
|
159
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
160
|
+
envelope = client._queue.get_nowait()
|
|
161
|
+
assert envelope["payload"]["properties"]["driver"] == "ai_agent"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# -----------------------------------------------------------------------------
|
|
165
|
+
# Bounded queue — never grow without bound
|
|
166
|
+
# -----------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_track_drops_when_queue_full(_patched_client: telemetry, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
170
|
+
"""Once the queue hits capacity, new events get dropped silently.
|
|
171
|
+
The CLI must never hang waiting for telemetry to drain."""
|
|
172
|
+
_write_creds(api_key="fxn_test")
|
|
173
|
+
client = _patched_client.get_client()
|
|
174
|
+
# Patch ``put_nowait`` to always raise Full so we don't have to
|
|
175
|
+
# actually fill the queue (capacity is 64).
|
|
176
|
+
import queue as _queue
|
|
177
|
+
|
|
178
|
+
with mock.patch.object(client, "_ensure_worker_started"):
|
|
179
|
+
with mock.patch.object(client._queue, "put_nowait", side_effect=_queue.Full):
|
|
180
|
+
client.track("CLI Command Invoked", {"command": "doctor"})
|
|
181
|
+
assert client._dropped == 1
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# -----------------------------------------------------------------------------
|
|
185
|
+
# First-run notice
|
|
186
|
+
# -----------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_notice_does_not_print_when_not_a_tty(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
|
190
|
+
"""CI / piped output never sees the notice — they're not going to
|
|
191
|
+
read it and it would clutter logs."""
|
|
192
|
+
monkeypatch.setattr(sys.stderr, "isatty", lambda: False, raising=False)
|
|
193
|
+
telemetry.maybe_print_first_run_notice()
|
|
194
|
+
out = capsys.readouterr()
|
|
195
|
+
assert "Fruxon CLI collects" not in out.err
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_notice_does_not_print_after_opt_out(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
|
199
|
+
"""If the user has already opted out, no nag."""
|
|
200
|
+
monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False)
|
|
201
|
+
_write_creds(telemetry_pref=False)
|
|
202
|
+
telemetry.maybe_print_first_run_notice()
|
|
203
|
+
out = capsys.readouterr()
|
|
204
|
+
assert "Fruxon CLI collects" not in out.err
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_notice_prints_once_then_persists_flag(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture) -> None:
|
|
208
|
+
"""First call prints, second call is silent."""
|
|
209
|
+
monkeypatch.setattr(sys.stderr, "isatty", lambda: True, raising=False)
|
|
210
|
+
telemetry.maybe_print_first_run_notice()
|
|
211
|
+
first = capsys.readouterr()
|
|
212
|
+
assert "Fruxon CLI collects" in first.err
|
|
213
|
+
|
|
214
|
+
telemetry.maybe_print_first_run_notice()
|
|
215
|
+
second = capsys.readouterr()
|
|
216
|
+
assert "Fruxon CLI collects" not in second.err
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|