(bug): telemetry fixes (#421)

This commit is contained in:
Tanvir Bhathal
2026-05-27 09:36:26 -07:00
committed by GitHub
parent f6156f480b
commit 0702f2793c
9 changed files with 22 additions and 219 deletions
-27
View File
@@ -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
+6 -1
View File
@@ -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<string>('');
const setModels = useAppStore((s) => s.setModels);
+1 -1
View File
@@ -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"}
{"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"}
+1 -1
View File
@@ -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'",
-11
View File
@@ -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
}
+12 -7
View File
@@ -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)
+1 -44
View File
@@ -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
-126
View File
@@ -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 == []
Generated
+1 -1
View File
@@ -4973,7 +4973,7 @@ wheels = [
[[package]]
name = "openjarvis"
version = "1.0.1"
version = "1.0.2"
source = { editable = "." }
dependencies = [
{ name = "click" },