feat(mem0-plugin): add PostHog telemetry across all hooks
Wire fire-and-forget PostHog telemetry into every lifecycle hook (session_start, stop, pre_compact, task_completed, user_prompt, post_tool_use) matching the same project key, endpoint, and payload structure used by the Python SDK, CLI, and OpenClaw. - 10% sampling on ALL events to keep PostHog costs predictable - Anonymous distinct_id: MD5(api_key) or SHA-256(username) - Opt-out via MEM0_TELEMETRY=false - Platform detection: Claude Code / Cursor / Codex - Never sends user content, API keys, or raw identifiers - 16 new tests (110 total passing)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <event_type> [--memory_count=N] [--categories_count=N]
|
||||
[--error_detected] [--file_paths_detected]
|
||||
[--source=<src>] [--tool=<name>]
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user