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.
Files changed (74) hide show
  1. {fruxon-0.8.2 → fruxon-0.8.4}/PKG-INFO +1 -1
  2. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/_version.py +2 -2
  3. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/__init__.py +77 -1
  4. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/auth.py +17 -0
  5. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/config.py +28 -1
  6. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/doctor.py +26 -0
  7. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/credentials.py +72 -1
  8. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/fruxon.py +2 -6
  9. fruxon-0.8.4/src/fruxon/telemetry.py +340 -0
  10. fruxon-0.8.4/tests/test_telemetry.py +216 -0
  11. {fruxon-0.8.2 → fruxon-0.8.4}/.gitignore +0 -0
  12. {fruxon-0.8.2 → fruxon-0.8.4}/HISTORY.md +0 -0
  13. {fruxon-0.8.2 → fruxon-0.8.4}/LICENSE +0 -0
  14. {fruxon-0.8.2 → fruxon-0.8.4}/README.md +0 -0
  15. {fruxon-0.8.2 → fruxon-0.8.4}/pyproject.toml +0 -0
  16. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/__init__.py +0 -0
  17. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/__main__.py +0 -0
  18. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/_ssl.py +0 -0
  19. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/_schema.py +0 -0
  20. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/_shared.py +0 -0
  21. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents.py +0 -0
  22. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_budget.py +0 -0
  23. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_draft.py +0 -0
  24. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_revisions.py +0 -0
  25. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/agents_tests.py +0 -0
  26. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/chat.py +0 -0
  27. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/completion.py +0 -0
  28. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/describe.py +0 -0
  29. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/examples.py +0 -0
  30. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/guides.py +0 -0
  31. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/integrations.py +0 -0
  32. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/keys.py +0 -0
  33. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/llm_providers.py +0 -0
  34. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/run.py +0 -0
  35. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/skills.py +0 -0
  36. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/tools.py +0 -0
  37. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli/trace.py +0 -0
  38. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/cli_auth.py +0 -0
  39. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/doctor.py +0 -0
  40. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/exceptions.py +0 -0
  41. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/models.py +0 -0
  42. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/output.py +0 -0
  43. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/params.py +0 -0
  44. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/__init__.py +0 -0
  45. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-agent-mode/SKILL.md +0 -0
  46. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-build-agent/SKILL.md +0 -0
  47. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-create-integration/SKILL.md +0 -0
  48. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-debug-revision/SKILL.md +0 -0
  49. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-meet/SKILL.md +0 -0
  50. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/skills/fruxon-use-integrations/SKILL.md +0 -0
  51. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/ui.py +0 -0
  52. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/update_check.py +0 -0
  53. {fruxon-0.8.2 → fruxon-0.8.4}/src/fruxon/validation.py +0 -0
  54. {fruxon-0.8.2 → fruxon-0.8.4}/tests/__init__.py +0 -0
  55. {fruxon-0.8.2 → fruxon-0.8.4}/tests/conftest.py +0 -0
  56. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_actor.py +0 -0
  57. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_budgets.py +0 -0
  58. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_cli.py +0 -0
  59. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_client.py +0 -0
  60. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_credentials.py +0 -0
  61. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_doctor.py +0 -0
  62. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_draft_evaluate_cli.py +0 -0
  63. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_drafts.py +0 -0
  64. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_fruxon.py +0 -0
  65. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_guides.py +0 -0
  66. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_output.py +0 -0
  67. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_params.py +0 -0
  68. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_schema.py +0 -0
  69. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_skills.py +0 -0
  70. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_ssl.py +0 -0
  71. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_test_chats.py +0 -0
  72. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_ui.py +0 -0
  73. {fruxon-0.8.2 → fruxon-0.8.4}/tests/test_update_check.py +0 -0
  74. {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.2
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.2'
22
- __version_tuple__ = version_tuple = (0, 8, 2)
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, 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
- The server-side ``PagingModelBinder`` reads ``page_token`` /
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(("page_token", page_token))
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