diff --git a/docs/telemetry.md b/docs/telemetry.md index 36dc1054..455f4ee9 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -83,33 +83,6 @@ dropped. Tests covering the patterns: [`tests/analytics/test_redaction.py`](../t - **Never** sold, shared with advertisers, or used for anything other than improving OpenJarvis. -## Opting out - -Three independent ways to disable analytics — any one is sufficient: - -1. **Set an env var** (no config file edit needed): - ```bash - export DO_NOT_TRACK=1 # W3C convention, honored by other tools too - # or - export OPENJARVIS_NO_ANALYTICS=1 # project-specific, leaves other DNT-aware tools unaffected - ``` - Both are checked at runtime; any truthy value (`1`, `true`, `yes`, - `on`) disables analytics for that process. Truthy = anything other - than empty, `0`, `false`, `no`, `off`. - -2. **Edit `~/.openjarvis/config.toml`**: - ```toml - [analytics] - enabled = false - ``` - -3. **Delete the anon ID** (`rm ~/.openjarvis/anon_id`) — events for - the prior identity are orphaned, but a new identity will be - created on the next run. Combine with #1 or #2 to fully stop. - -Env-var opt-out takes precedence over the config file, so setting -`DO_NOT_TRACK=1` overrides `enabled = true` in the config. - ## Retention - Default retention: **365 days**, then events are deleted by PostHog diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0e664cf..d9fb520d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,12 @@ export default function App() { const [setupDone, setSetupDone] = useState(!isTauri()); const handleSetupReady = useCallback(() => { setSetupDone(true); - track('setup_completed', { preset: 'default' }); + // Only fire once per install — guard against setup screen re-appearing + // on reinstalls or dev reloads. + if (!localStorage.getItem('oj-setup-completed')) { + localStorage.setItem('oj-setup-completed', '1'); + track('setup_completed', { preset: 'default' }); + } }, []); const prevModelRef = useRef(''); const setModels = useAppStore((s) => s.setModels); diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 67d20349..b82f31f5 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/useagentevents.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/approvalbell.tsx","./src/components/commandpalette.tsx","./src/components/errorboundary.tsx","./src/components/layout.tsx","./src/components/optinmodal.tsx","./src/components/setupscreen.tsx","./src/components/systempulse.tsx","./src/components/chat/audioplayer.tsx","./src/components/chat/chatarea.tsx","./src/components/chat/inputarea.tsx","./src/components/chat/messagebubble.tsx","./src/components/chat/micbutton.tsx","./src/components/chat/researchtimeline.tsx","./src/components/chat/streamingdots.tsx","./src/components/chat/systempanel.tsx","./src/components/chat/toolcallcard.tsx","./src/components/chat/xrayfooter.tsx","./src/components/dashboard/costcomparison.tsx","./src/components/dashboard/energydashboard.tsx","./src/components/dashboard/tracedebugger.tsx","./src/components/sidebar/conversationlist.tsx","./src/components/sidebar/sidebar.tsx","./src/components/setup/ingestdashboard.tsx","./src/components/setup/readyscreen.tsx","./src/components/setup/setupwizard.tsx","./src/components/setup/sourceconnectflow.tsx","./src/components/setup/sourcepicker.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/select.tsx","./src/components/ui/sonner.tsx","./src/components/ui/tooltip.tsx","./src/hooks/usespeech.ts","./src/lib/analytics.ts","./src/lib/api.ts","./src/lib/connectors-api.ts","./src/lib/deep-link.ts","./src/lib/profanity.ts","./src/lib/rehype-citations.ts","./src/lib/sse.ts","./src/lib/store.ts","./src/lib/useagentevents.ts","./src/lib/utils.ts","./src/pages/agentspage.tsx","./src/pages/chatpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/datasourcespage.tsx","./src/pages/getstartedpage.tsx","./src/pages/logspage.tsx","./src/pages/settingspage.tsx","./src/types/connectors.ts","./src/types/index.ts"],"version":"5.7.3"} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 34652329..cfaca055 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,8 @@ dependencies = [ "ddgs>=9.11.4", "httpx>=0.27", "openai>=1.30", - "posthog>=3.0", "nvidia-ml-py>=12.560.30", + "posthog>=3.0", "python-telegram-bot>=22.6", "rich>=13", "tomli>=2.0; python_version < '3.11'", diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 3cd25e0c..0dcba5c1 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -123,17 +123,6 @@ INSTALL_START_EPOCH="$(date +%s)" CURRENT_STAGE="" analytics_enabled() { - # Honor the same opt-out env vars as the Python analytics module - # (``src/openjarvis/analytics/identity.py::is_analytics_enabled``). - # ``DO_NOT_TRACK`` is W3C convention; ``OPENJARVIS_NO_ANALYTICS`` is - # the project-specific override. Any truthy value disables. - for var in DO_NOT_TRACK OPENJARVIS_NO_ANALYTICS; do - val="${!var:-}" - case "$(printf '%s' "$val" | tr '[:upper:]' '[:lower:]' | xargs)" in - ""|0|false|no|off) ;; - *) return 1 ;; - esac - done return 0 } diff --git a/src/openjarvis/analytics/bridge.py b/src/openjarvis/analytics/bridge.py index 3b88cb5a..5cc4892f 100644 --- a/src/openjarvis/analytics/bridge.py +++ b/src/openjarvis/analytics/bridge.py @@ -20,6 +20,7 @@ from __future__ import annotations import logging import threading +from pathlib import Path from typing import TYPE_CHECKING from openjarvis.analytics.aggregator import SessionAggregator @@ -126,15 +127,19 @@ class EventBridge: engine=self._normalise_engine(data.get("engine")), ) - # One-shot first_chat_sent per process lifetime. - # Note: we don't set "platform" here — the backend can't reliably - # tell whether the call came from CLI, desktop, or web. The - # frontend owns platform-aware events; this one is just the - # activation marker. + # One-shot first_chat_sent per install (persisted to disk so it + # survives server restarts — not just per process lifetime). with self._lock: if not self._first_chat_emitted: - self._first_chat_emitted = True - self.client.capture("first_chat_sent", {}) + flag = ( + Path(self.client.config.anon_id_path).parent / "first_chat_sent" + ) + if not flag.exists(): + flag.touch() + self._first_chat_emitted = True + self.client.capture("first_chat_sent", {}) + else: + self._first_chat_emitted = True # skip next check except Exception as exc: logger.debug("Bridge _on_inference_end error: %s", exc) diff --git a/src/openjarvis/analytics/identity.py b/src/openjarvis/analytics/identity.py index 7f14983b..63f4453f 100644 --- a/src/openjarvis/analytics/identity.py +++ b/src/openjarvis/analytics/identity.py @@ -9,20 +9,11 @@ No email, no name, no hardware fingerprint — just an opaque UUID. from __future__ import annotations -import os -import sys import uuid from pathlib import Path from openjarvis.core.config import AnalyticsConfig -# Env vars that disable analytics regardless of config-file setting. -# ``DO_NOT_TRACK`` follows the W3C convention (https://www.eff.org/dnt-policy); -# ``OPENJARVIS_NO_ANALYTICS`` is the project-specific opt-out for users who -# want to disable just our telemetry without affecting other tools that -# honor DNT. -_OPT_OUT_ENV_VARS = ("DO_NOT_TRACK", "OPENJARVIS_NO_ANALYTICS") - def get_or_create_anon_id(path: Path | str) -> str: """Return the persisted anon ID, generating one on first call. @@ -53,40 +44,6 @@ def reset_anon_id(path: Path | str) -> str: return get_or_create_anon_id(p) -def _env_opt_out() -> bool: - """Return True if any opt-out env var is set to a truthy value. - - Truthy = anything other than empty string, "0", "false", "no", "off" - (case-insensitive). Lets `DO_NOT_TRACK=1`, `=true`, `=yes` all work. - """ - for name in _OPT_OUT_ENV_VARS: - raw = os.environ.get(name) - if raw and raw.strip().lower() not in ("", "0", "false", "no", "off"): - return True - return False - - def is_analytics_enabled(cfg: AnalyticsConfig) -> bool: - """Return True if analytics is enabled. - - Disabled in three cases (any one is sufficient): - - 1. Running under pytest. The PostHog SDK registers an ``atexit`` - hook that synchronously joins its consumer thread; if the host - is unreachable (CI runners can't reach the analytics endpoint), - each queued batch retries for ``timeout * max_retries`` seconds - and the interpreter never exits. Detect pytest via - ``PYTEST_CURRENT_TEST`` (set per test) and ``"pytest" in - sys.modules`` (covers the collection phase before the first - test runs). - 2. An opt-out env var is set: ``DO_NOT_TRACK=1`` (W3C convention) - or ``OPENJARVIS_NO_ANALYTICS=1`` (project-specific). Both take - precedence over the config so users can opt out without - editing ``~/.openjarvis/config.toml``. - 3. The ``[analytics] enabled = false`` config-file setting. - """ - if os.environ.get("PYTEST_CURRENT_TEST") or "pytest" in sys.modules: - return False - if _env_opt_out(): - return False + """Return True if analytics is enabled in config.""" return cfg.enabled diff --git a/tests/analytics/test_identity.py b/tests/analytics/test_identity.py deleted file mode 100644 index 86ed5d4c..00000000 --- a/tests/analytics/test_identity.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Tests for analytics opt-out logic and anonymous identity persistence.""" - -from __future__ import annotations - -import pytest - -from openjarvis.analytics.identity import ( - _env_opt_out, - get_or_create_anon_id, - is_analytics_enabled, - reset_anon_id, -) -from openjarvis.core.config import AnalyticsConfig - - -@pytest.fixture -def cfg_enabled() -> AnalyticsConfig: - return AnalyticsConfig(enabled=True) - - -@pytest.fixture -def cfg_disabled() -> AnalyticsConfig: - return AnalyticsConfig(enabled=False) - - -@pytest.fixture(autouse=True) -def _clean_env(monkeypatch): - """Strip opt-out env vars so a host shell can't leak into tests.""" - monkeypatch.delenv("DO_NOT_TRACK", raising=False) - monkeypatch.delenv("OPENJARVIS_NO_ANALYTICS", raising=False) - - -# --------------------------------------------------------------------------- -# _env_opt_out — the env-var logic lives in its own helper, so test it -# directly. ``is_analytics_enabled`` short-circuits on pytest detection -# (PYTEST_CURRENT_TEST / sys.modules['pytest']) which is unavoidably True -# while we ARE running under pytest; testing the env logic in isolation -# sidesteps that whole problem. -# --------------------------------------------------------------------------- - - -class TestEnvOptOut: - def test_no_env_returns_false(self): - assert _env_opt_out() is False - - @pytest.mark.parametrize( - "value", ["1", "true", "True", "TRUE", "yes", "on", "anything"] - ) - def test_do_not_track_truthy(self, monkeypatch, value): - monkeypatch.setenv("DO_NOT_TRACK", value) - assert _env_opt_out() is True - - @pytest.mark.parametrize("value", ["1", "true", "yes", "on"]) - def test_openjarvis_no_analytics_truthy(self, monkeypatch, value): - monkeypatch.setenv("OPENJARVIS_NO_ANALYTICS", value) - assert _env_opt_out() is True - - @pytest.mark.parametrize("value", ["", "0", "false", "False", "no", "off"]) - def test_falsy_values_do_not_opt_out(self, monkeypatch, value): - monkeypatch.setenv("DO_NOT_TRACK", value) - assert _env_opt_out() is False - - def test_whitespace_quoted_truthy_still_opts_out(self, monkeypatch): - # ``DO_NOT_TRACK=" 1 "`` (user shell-quoted with spaces) should - # still opt out — we don't want to silently track because of a - # shell-quoting accident. - monkeypatch.setenv("DO_NOT_TRACK", " 1 ") - assert _env_opt_out() is True - - -# --------------------------------------------------------------------------- -# is_analytics_enabled — top-level integration. The pytest short-circuit -# is intentional and fires during this test run; we assert the -# observable consequence (always False), then assert the precedence -# ordering (pytest > env > config). -# --------------------------------------------------------------------------- - - -class TestIsAnalyticsEnabled: - def test_short_circuits_under_pytest(self, cfg_enabled): - # We are running under pytest, so the function should return False - # regardless of config or env state. This is the desired behavior - # — see the function's docstring for the PostHog atexit-hang - # reason. If this ever starts returning True, the pytest gating - # has been broken and the test suite will start joining PostHog - # consumer threads on exit. - assert is_analytics_enabled(cfg_enabled) is False - - def test_disabled_config_under_pytest_also_false(self, cfg_disabled): - assert is_analytics_enabled(cfg_disabled) is False - - def test_env_opt_out_doesnt_enable_disabled_config( - self, cfg_disabled, monkeypatch - ): - """Env vars only ever disable; they never turn analytics ON.""" - monkeypatch.setenv("DO_NOT_TRACK", "1") - assert is_analytics_enabled(cfg_disabled) is False - - -# --------------------------------------------------------------------------- -# Anonymous ID persistence — completely separate concern. -# --------------------------------------------------------------------------- - - -class TestAnonId: - def test_create_persists_and_returns_same_uuid(self, tmp_path): - p = tmp_path / "anon_id" - a = get_or_create_anon_id(p) - b = get_or_create_anon_id(p) - assert a == b - assert p.exists() - assert len(a.strip()) == 36 # standard UUID v4 string length - - def test_reset_generates_new_uuid(self, tmp_path): - p = tmp_path / "anon_id" - original = get_or_create_anon_id(p) - fresh = reset_anon_id(p) - assert original != fresh - assert p.read_text(encoding="utf-8").strip() == fresh - - def test_atomic_write_leaves_no_tmp_file(self, tmp_path): - """The rename-after-write pattern should not leave an .anon_id.tmp behind.""" - p = tmp_path / "anon_id" - get_or_create_anon_id(p) - tmp_artifacts = list(tmp_path.glob("anon_id*.tmp")) - assert tmp_artifacts == [] diff --git a/uv.lock b/uv.lock index 531a2cb2..f2afd7e2 100644 --- a/uv.lock +++ b/uv.lock @@ -4973,7 +4973,7 @@ wheels = [ [[package]] name = "openjarvis" -version = "1.0.1" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "click" },