diff --git a/mem0-plugin/scripts/on_post_tool_use.sh b/mem0-plugin/scripts/on_post_tool_use.sh index fc1d3ad17..49ff950e5 100755 --- a/mem0-plugin/scripts/on_post_tool_use.sh +++ b/mem0-plugin/scripts/on_post_tool_use.sh @@ -19,9 +19,17 @@ case "$TOOL_NAME" in mcp__mem0__add_memory) CATEGORY=$(echo "$INPUT" | jq -r '.tool_input.metadata.type // .tool_input.metadata.category // ""' 2>/dev/null || echo "") python3 "$SCRIPT_DIR/session_stats.py" add "$CATEGORY" 2>/dev/null || true + python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=add_memory 2>/dev/null & ;; mcp__mem0__search_memories|mcp__mem0__get_memories) python3 "$SCRIPT_DIR/session_stats.py" search 2>/dev/null || true + python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=search_memories 2>/dev/null & + ;; + mcp__mem0__delete_memory) + python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=delete_memory 2>/dev/null & + ;; + mcp__mem0__update_memory) + python3 "$SCRIPT_DIR/telemetry.py" tool_use --tool=update_memory 2>/dev/null & ;; esac diff --git a/mem0-plugin/scripts/on_pre_compact.sh b/mem0-plugin/scripts/on_pre_compact.sh index 28a1d86af..024d0ed22 100755 --- a/mem0-plugin/scripts/on_pre_compact.sh +++ b/mem0-plugin/scripts/on_pre_compact.sh @@ -21,6 +21,7 @@ INPUT=$(cat) # Fire REST API transcript backup in background (safety net if agent # can't complete the add_memory call before compaction finishes) echo "$INPUT" | python3 "$SCRIPT_DIR/on_pre_compact.py" --source=pre-compaction 2>/dev/null & +python3 "$SCRIPT_DIR/telemetry.py" pre_compact 2>/dev/null & cat <<'EOF' ## CRITICAL: Pre-Compaction Session Summary diff --git a/mem0-plugin/scripts/on_session_start.sh b/mem0-plugin/scripts/on_session_start.sh index 0aa973e61..b68c3c696 100755 --- a/mem0-plugin/scripts/on_session_start.sh +++ b/mem0-plugin/scripts/on_session_start.sh @@ -162,4 +162,7 @@ is being captured to mem0 in the background as `metadata.type=compact_summary`. EOF fi +# Telemetry (background, fire-and-forget) +python3 "$SCRIPT_DIR/telemetry.py" session_start --source="$SOURCE" --memory_count="${MEM0_COUNT:-0}" 2>/dev/null & + exit 0 diff --git a/mem0-plugin/scripts/on_stop.sh b/mem0-plugin/scripts/on_stop.sh index 1934a8651..87403ae63 100755 --- a/mem0-plugin/scripts/on_stop.sh +++ b/mem0-plugin/scripts/on_stop.sh @@ -26,6 +26,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi +# Telemetry: fire before report() deletes stats file +_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0") +python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null & + # Print session-end report REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") if [ -n "$REPORT" ]; then diff --git a/mem0-plugin/scripts/on_stop_codex.sh b/mem0-plugin/scripts/on_stop_codex.sh index 16c5a023f..cdad20a83 100755 --- a/mem0-plugin/scripts/on_stop_codex.sh +++ b/mem0-plugin/scripts/on_stop_codex.sh @@ -33,6 +33,10 @@ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then exit 0 fi +# Telemetry: fire before report() deletes stats file +_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0") +python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null & + # Session-end report (best-effort, must not break JSON output) REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") REPORT_BLOCK="" diff --git a/mem0-plugin/scripts/on_stop_cursor.sh b/mem0-plugin/scripts/on_stop_cursor.sh index f9412715f..ebad12edb 100755 --- a/mem0-plugin/scripts/on_stop_cursor.sh +++ b/mem0-plugin/scripts/on_stop_cursor.sh @@ -27,6 +27,10 @@ if [ "$LOOP_COUNT" -gt 1 ]; then exit 0 fi +# Telemetry: fire before report() deletes stats file +_TELEM_CAT=$(python3 "$SCRIPT_DIR/session_stats.py" peek 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d.get('categories',[])))" 2>/dev/null || echo "0") +python3 "$SCRIPT_DIR/telemetry.py" stop --categories_count="$_TELEM_CAT" 2>/dev/null & + # Session-end report (best-effort) REPORT=$(python3 "$SCRIPT_DIR/session_stats.py" report 2>/dev/null || echo "") REPORT_BLOCK="" diff --git a/mem0-plugin/scripts/on_task_completed.sh b/mem0-plugin/scripts/on_task_completed.sh index dcb90f7b4..2d8eef903 100755 --- a/mem0-plugin/scripts/on_task_completed.sh +++ b/mem0-plugin/scripts/on_task_completed.sh @@ -37,4 +37,7 @@ Only store genuinely useful learnings — skip if the task was trivial. Include \`app_id\` = \`"$_PROJECT"\` as a top-level parameter in every \`add_memory\` call (not in metadata). EOF +# Telemetry (background, fire-and-forget) +python3 "$SCRIPT_DIR/telemetry.py" task_completed 2>/dev/null & + exit 0 diff --git a/mem0-plugin/scripts/on_user_prompt.sh b/mem0-plugin/scripts/on_user_prompt.sh index 0389dfbac..96b0570f5 100755 --- a/mem0-plugin/scripts/on_user_prompt.sh +++ b/mem0-plugin/scripts/on_user_prompt.sh @@ -38,6 +38,12 @@ fi # Detect file paths in the prompt (no API needed) FILE_PATHS=$(echo "$PROMPT" | grep -oE '([a-zA-Z0-9_./-]+\.(py|ts|tsx|js|jsx|rs|go|rb|java|sh|yaml|yml|json|toml|md|sql|css|html))\b' 2>/dev/null | head -5 || echo "") +# Telemetry (background, fire-and-forget) +_TELEM_ARGS="" +[ -n "$HAS_ERROR" ] && _TELEM_ARGS="$_TELEM_ARGS --error_detected" +[ -n "$FILE_PATHS" ] && _TELEM_ARGS="$_TELEM_ARGS --file_paths_detected" +python3 "$SCRIPT_DIR/telemetry.py" user_prompt $_TELEM_ARGS 2>/dev/null & + # No API key — emit detections only, skip search rubric if [ -z "${MEM0_API_KEY:-}" ]; then if [ -n "$HAS_ERROR" ]; then diff --git a/mem0-plugin/scripts/telemetry.py b/mem0-plugin/scripts/telemetry.py new file mode 100644 index 000000000..6fa4402c3 --- /dev/null +++ b/mem0-plugin/scripts/telemetry.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Lightweight fire-and-forget telemetry for the mem0 plugin. + +Sends anonymous usage events to PostHog using the same project key and +endpoint as the mem0 Python SDK and CLI. No posthog library dependency — +uses stdlib urllib directly (same pattern as cli/python telemetry_sender.py). + +CLI usage (called from hooks as a background subprocess): + python3 telemetry.py [--memory_count=N] [--categories_count=N] + [--error_detected] [--file_paths_detected] + [--source=] [--tool=] + +Opt-out: set MEM0_TELEMETRY=false (or 0/no/off) to disable all telemetry. + +Never sends: user content, memory content, API keys, raw user/project IDs. +Only sends: event type, platform, plugin version, anonymized hashes, counts. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import platform +import random +import sys +import urllib.error +import urllib.request + +PLUGIN_VERSION = "0.2.1" + +POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX" +POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/" +REQUEST_TIMEOUT = 2 + +# All events sampled at 10% to keep PostHog costs predictable. +SAMPLE_RATE = 0.1 + + +def _sha256(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _distinct_id() -> str: + """Stable anonymous ID: MD5 of API key if available, else SHA-256 of username.""" + api_key = os.environ.get("MEM0_API_KEY") or os.environ.get("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY") or "" + if api_key: + return hashlib.md5(api_key.encode()).hexdigest() + user_id = os.environ.get("MEM0_RESOLVED_USER_ID") or os.environ.get("USER") or "unknown" + return _sha256(user_id) + + +def detect_platform() -> str: + if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_PLUGIN_ROOT"): + return "claude-code" + if os.environ.get("CURSOR_PLUGIN_ROOT"): + return "cursor" + if os.environ.get("CODEX_PLUGIN_ROOT"): + return "codex" + return "unknown" + + +def is_enabled() -> bool: + return os.environ.get("MEM0_TELEMETRY", "true").lower() not in ("false", "0", "no", "off") + + +def _should_sample() -> bool: + return random.random() < SAMPLE_RATE + + +def build_posthog_payload(event_name: str, properties: dict | None = None) -> dict: + project_id = os.environ.get("MEM0_PROJECT_ID") or "unknown" + return { + "api_key": POSTHOG_API_KEY, + "distinct_id": _distinct_id(), + "event": event_name, + "properties": { + "source": "plugin", + "platform": detect_platform(), + "plugin_version": PLUGIN_VERSION, + "project_hash": _sha256(project_id), + "os": sys.platform, + "os_version": platform.version(), + "sample_rate": SAMPLE_RATE, + "$process_person_profile": False, + "$lib": "posthog-python", + **(properties or {}), + }, + } + + +def send(payload: dict) -> None: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + POSTHOG_HOST, + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT): + pass + except Exception: + pass + + +def emit(event_type: str, properties: dict | None = None) -> None: + if not is_enabled(): + return + event_name = f"plugin.{event_type}" + if not _should_sample(): + return + send(build_posthog_payload(event_name, properties)) + + +def main() -> int: + if not is_enabled(): + return 0 + if len(sys.argv) < 2: + return 1 + + event_type = sys.argv[1] + properties: dict = {} + + for arg in sys.argv[2:]: + if arg.startswith("--memory_count="): + try: + properties["memory_count"] = int(arg.split("=", 1)[1]) + except ValueError: + pass + elif arg.startswith("--categories_count="): + try: + properties["categories_count"] = int(arg.split("=", 1)[1]) + except ValueError: + pass + elif arg == "--error_detected": + properties["error_detected"] = True + elif arg == "--file_paths_detected": + properties["file_paths_detected"] = True + elif arg.startswith("--source="): + properties["source_detail"] = arg.split("=", 1)[1] + elif arg.startswith("--tool="): + properties["tool"] = arg.split("=", 1)[1] + + emit(event_type, properties) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/mem0-plugin/tests/test_telemetry.py b/mem0-plugin/tests/test_telemetry.py new file mode 100644 index 000000000..17db658f8 --- /dev/null +++ b/mem0-plugin/tests/test_telemetry.py @@ -0,0 +1,163 @@ +"""Tests for telemetry.py — fire-and-forget PostHog plugin telemetry.""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.error + +SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "scripts") +sys.path.insert(0, os.path.abspath(SCRIPTS_DIR)) + + +def test_import_succeeds(): + import telemetry + + assert hasattr(telemetry, "emit") + assert hasattr(telemetry, "main") + + +def test_opt_out_skips_send(monkeypatch): + import telemetry + + monkeypatch.setenv("MEM0_TELEMETRY", "false") + sent = [] + monkeypatch.setattr(telemetry, "send", lambda p: sent.append(p)) + telemetry.emit("session_start") + assert sent == [] + + +def test_opt_out_variants(monkeypatch): + import telemetry + + for val in ("0", "no", "off", "FALSE", "No"): + monkeypatch.setenv("MEM0_TELEMETRY", val) + assert not telemetry.is_enabled() + + +def test_enabled_by_default(monkeypatch): + import telemetry + + monkeypatch.delenv("MEM0_TELEMETRY", raising=False) + assert telemetry.is_enabled() + + +def test_posthog_payload_structure(monkeypatch): + import telemetry + + monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "testuser") + monkeypatch.setenv("MEM0_PROJECT_ID", "test-project") + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + + payload = telemetry.build_posthog_payload("plugin.session_start", {"memory_count": 5}) + + assert payload["api_key"] == telemetry.POSTHOG_API_KEY + assert payload["event"] == "plugin.session_start" + assert "distinct_id" in payload + assert payload["properties"]["source"] == "plugin" + assert payload["properties"]["plugin_version"] == "0.2.1" + assert payload["properties"]["memory_count"] == 5 + assert payload["properties"]["$process_person_profile"] is False + + raw = json.dumps(payload) + assert "testuser" not in raw + assert "test-project" not in raw + + +def test_distinct_id_from_api_key(monkeypatch): + import hashlib + + import telemetry + + monkeypatch.setenv("MEM0_API_KEY", "m0-testkey123") + expected = hashlib.md5(b"m0-testkey123").hexdigest() + assert telemetry._distinct_id() == expected + + +def test_distinct_id_fallback_no_key(monkeypatch): + import telemetry + + monkeypatch.delenv("MEM0_API_KEY", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_OPTION_MEM0_API_KEY", raising=False) + monkeypatch.setenv("MEM0_RESOLVED_USER_ID", "kartik") + assert telemetry._distinct_id() == telemetry._sha256("kartik") + + +def test_hash_deterministic(): + import telemetry + + h1 = telemetry._sha256("same-value") + h2 = telemetry._sha256("same-value") + assert h1 == h2 + assert h1 != telemetry._sha256("different-value") + + +def test_platform_claude_code(monkeypatch): + import telemetry + + monkeypatch.setenv("CLAUDECODE", "1") + monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False) + assert telemetry.detect_platform() == "claude-code" + + +def test_platform_cursor(monkeypatch): + import telemetry + + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + monkeypatch.setenv("CURSOR_PLUGIN_ROOT", "/path") + monkeypatch.delenv("CODEX_PLUGIN_ROOT", raising=False) + assert telemetry.detect_platform() == "cursor" + + +def test_platform_codex(monkeypatch): + import telemetry + + monkeypatch.delenv("CLAUDECODE", raising=False) + monkeypatch.delenv("CLAUDE_PLUGIN_ROOT", raising=False) + monkeypatch.delenv("CURSOR_PLUGIN_ROOT", raising=False) + monkeypatch.setenv("CODEX_PLUGIN_ROOT", "/path") + assert telemetry.detect_platform() == "codex" + + +def test_sampling_drops_at_high_random(monkeypatch): + import telemetry + + monkeypatch.setattr(telemetry.random, "random", lambda: 0.5) + assert telemetry._should_sample() is False + + +def test_sampling_sends_at_low_random(monkeypatch): + import telemetry + + monkeypatch.setattr(telemetry.random, "random", lambda: 0.05) + assert telemetry._should_sample() is True + + +def test_send_fails_silently(monkeypatch): + import telemetry + + def raise_error(req, timeout): + raise urllib.error.URLError("connection refused") + + monkeypatch.setattr(telemetry.urllib.request, "urlopen", raise_error) + telemetry.send({"event": "test"}) + + +def test_cli_exits_zero_when_disabled(monkeypatch): + import telemetry + + monkeypatch.setenv("MEM0_TELEMETRY", "false") + monkeypatch.setattr(sys, "argv", ["telemetry.py", "session_start"]) + assert telemetry.main() == 0 + + +def test_cli_no_args_exits_nonzero(monkeypatch): + import telemetry + + monkeypatch.delenv("MEM0_TELEMETRY", raising=False) + monkeypatch.setattr(sys, "argv", ["telemetry.py"]) + assert telemetry.main() == 1