From 6d89b3b33eb71dffc10867414ee98d29116e5bb0 Mon Sep 17 00:00:00 2001 From: Saket Aryan Date: Wed, 16 Sep 2026 20:36:23 +0530 Subject: [PATCH] fix(plugins): rotate the anonymous id when the account goes, and verify legacy rows Three review findings from @kartik-mem0 on this PR. Anonymous id reuse, reported twice and one defect. The id is offered to PostHog as $anon_distinct_id on first sign-in and that merge is permanent, so keeping it after a logout or a key change puts every later anonymous event on the account that just left. It is now rotated on both routes, and 'aliased' is cleared with it so the fresh id can be merged into whatever account comes next. Rotation is deliberately not triggered by a plain lookup failure with no cached email: there is no previous account to leak to, and churning ids there would fragment the person for anyone offline on first run. Legacy rows are verified instead of adopted. A row written before fingerprints existed carries an email and no fingerprint; adopting the current key bound that key to the previous account's email permanently, and every run after agreed with itself. It now resolves once and takes the answer. If the lookup fails it keeps the cached email and retries next flush rather than dropping a real attribution, which is safe because the network that failed /v1/ping/ is about to fail the PostHog POST too. My original comment justifying the shortcut claimed the check would cost a request on every flush forever; that was wrong, the fingerprint is stored after one success. A failed upgrade claim is released. The sentinel was created before the marker rewrite and left behind if the rewrite failed, so claim_version_change returned early on every later run and that version's upgrade was never recorded again. Five tests, covering both rotation routes, legacy verification, the firewalled legacy case, and retrying a failed upgrade claim. 300 passed, 8 skipped. Claude-Session: https://claude.ai/code/session_01C7tEmH86HAr7GoAAKCEHZb --- .../agent-plugin-core/python/telemetry.py | 60 ++++++++-- .../antigravity-plugin/core/telemetry.py | 60 ++++++++-- .../claude-code-plugin/core/telemetry.py | 60 ++++++++-- .../tests/test_telemetry.py | 103 ++++++++++++++++++ integrations/codex-plugin/core/telemetry.py | 60 ++++++++-- integrations/cursor-plugin/core/telemetry.py | 60 ++++++++-- integrations/kimi-plugin/core/telemetry.py | 60 ++++++++-- .../mem0-agent-plugin/core/telemetry.py | 60 ++++++++-- 8 files changed, 467 insertions(+), 56 deletions(-) diff --git a/integrations/agent-plugin-core/python/telemetry.py b/integrations/agent-plugin-core/python/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/agent-plugin-core/python/telemetry.py +++ b/integrations/agent-plugin-core/python/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/antigravity-plugin/core/telemetry.py b/integrations/antigravity-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/antigravity-plugin/core/telemetry.py +++ b/integrations/antigravity-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/claude-code-plugin/core/telemetry.py b/integrations/claude-code-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/claude-code-plugin/core/telemetry.py +++ b/integrations/claude-code-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/claude-code-plugin/tests/test_telemetry.py b/integrations/claude-code-plugin/tests/test_telemetry.py index 363bf2368..8d83e4a7b 100644 --- a/integrations/claude-code-plugin/tests/test_telemetry.py +++ b/integrations/claude-code-plugin/tests/test_telemetry.py @@ -265,6 +265,109 @@ def test_an_unresolvable_key_falls_back_to_the_anonymous_id(isolated_env, monkey assert telemetry.resolve_distinct_id()[0].startswith("code-anon-") +def test_logging_out_does_not_leave_events_on_the_previous_account(isolated_env, monkeypatch): + """Review finding: clearing the email kept an id already merged into a person. + + The anonymous id is offered to PostHog as $anon_distinct_id on first sign-in, + and that merge is permanent. Keeping it after the key goes away means every + later anonymous event lands on the account that just left. + """ + # Run anonymously first, which is the only way an id exists to be merged. + merged = telemetry.anonymous_id() + + monkeypatch.setenv("MEM0_API_KEY", "key-for-account-a") + with patch.object(telemetry, "_resolve_email", lambda key: "a@example.com"): + identified, alias = telemetry.resolve_distinct_id() + assert identified == "a@example.com" + assert alias == merged, "the anonymous id was merged into this account" + + monkeypatch.delenv("MEM0_API_KEY", raising=False) + after_logout, logout_alias = telemetry.resolve_distinct_id() + + assert after_logout.startswith("code-anon-") + assert after_logout != merged, "reused an id already merged into the previous account" + assert logout_alias == "" + assert "aliased" not in telemetry._read_identity(), "rotated id must be aliasable again" + + +def test_a_changed_key_that_will_not_resolve_rotates_the_anonymous_id(isolated_env, monkeypatch): + """Same leak by the other route: fingerprint disagrees and the lookup fails.""" + merged = telemetry.anonymous_id() + monkeypatch.setenv("MEM0_API_KEY", "key-for-account-a") + with patch.object(telemetry, "_resolve_email", lambda key: "a@example.com"): + telemetry.resolve_distinct_id() + + monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b") + with patch.object(telemetry, "_resolve_email", lambda key: ""): + after, alias = telemetry.resolve_distinct_id() + + assert after.startswith("code-anon-") + assert after != merged + assert alias == "" + assert "email" not in telemetry._read_identity() + + +def test_a_legacy_cached_email_is_verified_before_the_key_is_bound(isolated_env, monkeypatch): + """Review finding: a key changed before upgrading bound the wrong account. + + Rows written before fingerprints existed carry an email and no fingerprint. + Adopting the current key without checking pinned that key to the previous + account's email, and every run after that agreed with itself. + """ + telemetry._write_identity({"email": "old@example.com", "anonymous_id": "code-anon-seed"}) + monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b") + + with patch.object(telemetry, "_resolve_email", lambda key: "new@example.com"): + resolved, alias = telemetry.resolve_distinct_id() + + assert resolved == "new@example.com" + assert alias == "", "email to email must never alias; it merges two real people" + stored = telemetry._read_identity() + assert stored["email"] == "new@example.com" + assert stored["key_fingerprint"] == telemetry._digest("key-for-account-b") + + +def test_a_legacy_row_keeps_working_when_the_account_cannot_be_checked(isolated_env, monkeypatch): + """Firewalled users must not lose attribution, and must not bind unverified. + + The same network that fails /v1/ping/ fails the PostHog POST, so nothing is + delivered under the unverified identity while this holds. + """ + telemetry._write_identity({"email": "old@example.com"}) + monkeypatch.setenv("MEM0_API_KEY", "key-for-account-b") + + with patch.object(telemetry, "_resolve_email", lambda key: ""): + resolved, _ = telemetry.resolve_distinct_id() + + assert resolved == "old@example.com" + assert "key_fingerprint" not in telemetry._read_identity(), "bound an unverified key" + + +def test_a_failed_upgrade_claim_can_be_retried(isolated_env, monkeypatch): + """Review finding: a failed rewrite left the sentinel and suppressed forever. + + claim_version_change returns early on FileExistsError, and the marker still + holds the old version, so the upgrade for that version was never recorded + again on that machine. + """ + telemetry.claim_install() + state_path = memory_core.data_dir() / "install-state.json" + state = json.loads(state_path.read_text()) + state["plugin_version"] = "0.0.1-old" + state_path.write_text(json.dumps(state), encoding="utf-8") + + real_replace = Path.replace + + def failing_replace(self, target): + raise OSError("disk full") + + monkeypatch.setattr(Path, "replace", failing_replace) + assert telemetry.claim_version_change() is None + + monkeypatch.setattr(Path, "replace", real_replace) + assert telemetry.claim_version_change() == "0.0.1-old", "sentinel suppressed the retry" + + def test_first_run_is_not_flipped_by_writing_the_identity_file(isolated_env): """The identity file is written by a successful flush, not by recording. diff --git a/integrations/codex-plugin/core/telemetry.py b/integrations/codex-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/codex-plugin/core/telemetry.py +++ b/integrations/codex-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/cursor-plugin/core/telemetry.py b/integrations/cursor-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/cursor-plugin/core/telemetry.py +++ b/integrations/cursor-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/kimi-plugin/core/telemetry.py b/integrations/kimi-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/kimi-plugin/core/telemetry.py +++ b/integrations/kimi-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon diff --git a/integrations/mem0-agent-plugin/core/telemetry.py b/integrations/mem0-agent-plugin/core/telemetry.py index 371a9b252..274174a9e 100644 --- a/integrations/mem0-agent-plugin/core/telemetry.py +++ b/integrations/mem0-agent-plugin/core/telemetry.py @@ -272,6 +272,25 @@ def anonymous_id(identity: dict[str, str] | None = None) -> str: return created +def _rotate_anonymous_id(identity: dict[str, str]) -> str: + """Mint a fresh anonymous id because the account context is gone. + + The previous id may already have been merged into a person profile by an + $identify, and that merge is permanent. Reusing it after a logout or a key + change attributes everything that follows to the account that just went + away, which is the same misattribution the key fingerprint exists to stop, + only arriving through the anonymous path instead. + + `aliased` is cleared with it: the new id has never been merged, so it is + eligible to be aliased into whatever account comes next. + """ + created = f"code-anon-{uuid.uuid4().hex}" + identity["anonymous_id"] = created + identity.pop("aliased", None) + _write_identity(identity) + return created + + def _install_state_path() -> Path: return memory_core.data_dir() / "install-state.json" @@ -400,11 +419,19 @@ def claim_version_change() -> str | None: state["plugin_version"] = memory_core.PLUGIN_VERSION state["upgraded_at"] = memory_core.utc_now() + temporary = path.with_suffix(f".{os.getpid()}.tmp") try: - temporary = path.with_suffix(f".{os.getpid()}.tmp") temporary.write_text(json.dumps(state), encoding="utf-8") temporary.replace(path) except OSError: + # Release the claim. The marker still records the old version, so + # without this the sentinel makes claim_version_change return early on + # every later run and this version's upgrade is never recorded again. + for leftover in (sentinel, temporary): + try: + leftover.unlink() + except OSError: + pass return None return previous @@ -727,26 +754,43 @@ def resolve_distinct_id() -> tuple[str, str]: if recorded == fingerprint: return email, "" if not recorded: - # Rows written before fingerprints existed. Adopt the current key - # rather than re-resolving: otherwise every existing user pays an - # uncached /v1/ping/ on every flush, forever, and a firewalled one - # pays the full timeout each time. + # Rows written before fingerprints existed. Verify rather than + # adopt: a key changed before the upgrade would otherwise bind the + # new key to the previous account's email, permanently, and the + # fingerprint would then agree with itself forever after. + verified = _resolve_email(key) + if not verified: + # Offline, firewalled, or the API is down. Keep the previous + # behaviour and retry on the next flush rather than dropping a + # real account attribution. Safe because the same network that + # failed /v1/ping/ is about to fail the PostHog POST, so nothing + # is delivered under the unverified identity in the meantime. + return email, "" + identity["email"] = verified identity["key_fingerprint"] = fingerprint _write_identity(identity) - return email, "" + return verified, "" if not key: # No key to verify the account with; do not keep attributing to it. if email: identity.pop("email", None) identity.pop("key_fingerprint", None) - _write_identity(identity) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" resolved = _resolve_email(key) if not resolved: # The key changed and will not resolve (revoked, offline, API down). - # Do not keep attributing to the previous account. + # Reaching here with an email means the recorded fingerprint disagreed, + # so the key really did change. Drop the account and rotate: the stored + # anonymous id may already be merged into that account's person, and + # reusing it would keep the events on the profile we are trying to + # leave. + if email: + identity.pop("email", None) + identity.pop("key_fingerprint", None) + return _rotate_anonymous_id(identity), "" return anonymous_id(identity), "" # Alias only when going anonymous -> email for the first time. Once an anon