#7347·mem0

bug(vector_stores/azure-ai-search): telemetry helpers operate on the memory index — a user's memory is silently re-scoped to the telemetry id

Author: warun7Created Sep 16, 2026Updated Sep 16, 2026
Labelsbugsdk-typescript

Component

TypeScript SDK

Description

Summary

AzureAISearch.getUserId() and setUserId() are the VectorStore interface's "migrations collection" helpers, but both use this.searchClient, which initialize() binds to this.indexName — the memory index (mem0-ts/src/oss/src/vector_stores/azure_ai_search.ts:122-126). The memory_migrations index that getUserId() creates at :638 is never read or written for data.

setUserId() (:676-701) searches the memory index, takes the first memory document's id, and issues mergeOrUploadDocuments([{ id: <that memory's id>, user_id: <telemetry id> }]) at :691. user_id is filterable: true on the memory index (:188-195) and every read path filters on it (:357-362, :396, :416, :427, :570-575). So that memory:

  • stops matching its owner's user_id filter, so it disappears from their search() / getAll();
  • can no longer be deleted by their deleteAll();
  • and starts matching the telemetry-id scope instead.

getUserId() (:613-670) is the mirror image: it searches the same memory index at :642 and returns the first memory document's user_id as this instance's telemetry id — another tenant's identifier — or, if no document has one, uploads a phantom { id: <uuid>, user_id: <random> } row into the memories index at :658.

This runs automatically. The constructor fires _autoInitialize() (memory/index.ts:257, :268), which ends in _initializeTelemetry() (:587) → _getTelemetryId() (:600) → vectorStore.setUserId(...) (:611). _captureEvent() (:621) calls the same helper on every add/search/update/delete/deleteAll/reset/ getAll. There is no opt-in, and MEM0_TELEMETRY=false does not prevent it (see below).

Steps to Reproduce

Drop this file in mem0-ts/src/oss/tests/ and run:

pnpm exec jest --config jest.config.js --testPathPattern azure_telemetry

It mocks only the Azure SDK; Memory and AzureAISearch are the repo's real code.

/// <reference types="jest" />
process.env.MEM0_TELEMETRY = "false";          // proving the opt-out does not help
process.env.MEM0_DIR = "/tmp/mem0-azure-repro"; // writable, so the cached telemetry id is used
process.env.OPENAI_API_KEY = process.env.OPENAI_API_KEY || "sk-dummy";

jest.mock("@azure/search-documents", () => {
  const calls: any[] = [];
  (globalThis as any).__azureCalls = calls;
  const rec = (c: any) => calls.push(c);

  class SearchClient {
    index: string;
    constructor(_endpoint: string, index: string, _cred: any) {
      this.index = index;
      rec({ op: "new SearchClient", index });
    }
    async search(_q: string, opts?: any) {
      rec({ op: "search", index: this.index, opts });
      const results = (async function* () {
        // An existing memory that belongs to user "alice".
        yield { document: { id: "memory-doc-1", user_id: "alice", data: "alice likes tea" } };
      })();
      return { results };
    }
    async mergeOrUploadDocuments(docs: any[]) {
      rec({ op: "mergeOrUploadDocuments", index: this.index, docs });
    }
    async uploadDocuments(docs: any[]) {
      rec({ op: "uploadDocuments", index: this.index, docs });
    }
    async deleteDocuments() {}
    async getDocument() { return null; }
  }

  class SearchIndexClient {
    constructor(_e: string, _c: any) {}
    async *listIndexes() { yield { name: "mem0" }; }
    async createOrUpdateIndex(i: any) { rec({ op: "createOrUpdateIndex", name: i.name }); }
    async deleteIndex() {}
  }

  return { SearchClient, SearchIndexClient, AzureKeyCredential: class {} };
});

test("azure-ai-search telemetry writes to the memory index", async () => {
  const { Memory } = await import("../src/memory");

  const memory = new Memory({
    disableHistory: true,
    embedder: { provider: "openai", config: { apiKey: "sk-dummy", model: "text-embedding-3-small" } },
    llm: { provider: "openai", config: { apiKey: "sk-dummy", model: "gpt-5-mini" } },
    vectorStore: {
      provider: "azure-ai-search",
      config: { serviceName: "svc", collectionName: "mem0", apiKey: "k", dimension: 4, embeddingModelDims: 4 },
    },
  } as any);

  try {
    await memory.getAll({ filters: { user_id: "alice" } } as any);
  } catch {}

  const calls = (globalThis as any).__azureCalls as any[];
  console.log("MEM0_TELEMETRY =", process.env.MEM0_TELEMETRY);
  console.log(
    "DOCUMENT WRITES:",
    JSON.stringify(calls.filter((c) => c.op === "mergeOrUploadDocuments" || c.op === "uploadDocuments"), null, 2),
  );
  expect(calls.some((c) => c.op === "mergeOrUploadDocuments" && c.index === "mem0")).toBe(true);
});

Expected Behavior

Telemetry bookkeeping should not touch the memory index. getUserId()/setUserId() should read and write memory_migrations — the collection getUserId() already creates, and the pattern every other store follows. A memory's user_id should only ever change through an explicit update().

Actual Behavior

MEM0_TELEMETRY = false
DOCUMENT WRITES: [
  {
    "op": "mergeOrUploadDocuments",
    "index": "mem0",
    "docs": [
      {
        "id": "memory-doc-1",
        "user_id": "73aba3f1-73f9-4d27-b778-f9abd8e53ca4"
      }
    ]
  }
]

memory-doc-1 came back from the store with user_id: "alice". One ordinary call later, that document has been rewritten to 73aba3f1-… — which is the SDK's own cached telemetry id, written to $MEM0_DIR/config.json (client/config.ts:81-96):

{ "user_id": "73aba3f1-73f9-4d27-b778-f9abd8e53ca4" }

The write goes to index mem0. No memory_migrations document is ever written.

With the fake store implementing the same user_id eq '…' filter the real store sends, seeding one memory owned by alice:

memory-doc-1 owner before      : alice
memory-doc-1 owner after       : 1c46ee2d-39fd-491a-bd38-988d8d08e237
documents in index             : 1
memories still visible to alice: 0

The document is still in the index — it has just been moved out of alice's scope. Nothing in the call sequence asked for that, and nothing reported it.

Environment

  • mem0 version: mem0-ts @ main (0df3e4b8)
  • Node version: 24.18.0 (CI targets 20 / 22)
  • OS: macOS 15 (darwin), arm64
  • Vector store: azure-ai-search

How You Verified This

What I Ran

pnpm exec jest --config jest.config.js --testPathPattern azure_telemetry in mem0-ts/, against the test above. I also read the code paths directly: azure_ai_search.ts:122-126 (client binding), :188-195 (user_id filterable), :357-362 / :396 / :416 / :427 / :570-575 (filtered reads), :607-610 (getUserId docstring), :613-670 (getUserId), :676-701 (setUserId), and memory/index.ts:253, :257, :268, :587, :600-613, :621-624 (the trigger), plus utils/telemetry.ts:56 (the MEM0_TELEMETRY guard).

What I Saw

The literal output in "Actual Behavior" above: a mergeOrUploadDocuments call against index mem0 whose payload replaces a real memory's user_id with the cached telemetry id. $MEM0_DIR/config.json holds that same id, so it is the SDK's telemetry identity, not a user id.

Why This Is a Bug

  • The two helpers are documented as the migrations-collection pair — getUserId()'s own docstring (:607-610) says "Get user ID from memory_migrations collection" — and memory_migrations is created at :638 and then never used for data.
  • Every other store keeps a separate namespace for these, and one of them is tested for it: src/oss/tests/oracledb.unit.test.ts:373-386 asserts an INSERT INTO memory_migrations with binds = { user_id: userId }. So "write the migrations store, not the memory store" is the established contract, not an interpretation.
  • azure-ai-search is a documented TypeScript vector store — docs/open-source/configuration.mdx:124 lists it in the TypeScript column alongside memory, qdrant, pgvector, redis, supabase, vectorize, milvus — and it has no tests at all in mem0-ts/src/oss/tests/ (only azure-embedder.test.ts exists).
  • user_id is the scope key the whole read surface filters on, so overwriting it moves the memory out of its owner's scope. That is silent: no error, no log, and getAll/search simply stop returning a memory the user successfully wrote.
  • Disabling telemetry does not prevent it. MEM0_TELEMETRY is checked inside UnifiedTelemetry.captureEvent (utils/telemetry.ts:56), which is called after _getTelemetryId() in _captureEvent (memory/index.ts:621-624). The vector-store write happens first, so MEM0_TELEMETRY=false suppresses the network event and nothing else. The repro above runs with it false.

What I Ruled Out

  • Not a mock artifact. The mock only stands in for @azure/search-documents; Memory and AzureAISearch are unmodified repo code, and the index name in the recorded call (mem0) comes from this.indexName in the real constructor.
  • The memory_migrations index is created, just unused. getUserId() does call indexClient.createOrUpdateIndex(migrationIndex) at :638; the reads and writes that follow use this.searchClient instead. So this is not "the migrations index is missing".
  • searchClient really is the memory client, not a second client bound to migrations — there is exactly one new SearchClient in initialize() (:122-126) and it is constructed with this.indexName.
  • The cross-tenant read is narrower than the write. getOrCreateMem0UserId() (client/config.ts:81-96) returns a cached id from $MEM0_DIR/~/.mem0/config.json, and only returns null when there is no filesystem or the read/write throws. So the "returns another tenant's id" path needs a no-fs or read-only environment (serverless/edge), while the write happens on the ordinary Node path regardless. The repro demonstrates the write; I set a writable MEM0_DIR specifically so the cached id would be used. With an unwritable MEM0_DIR the same code instead reads "alice" out of the memory index and writes that value back.
  • Not the entity-store/table-sharing reports (#7003, #6606) — this is the telemetry helper pair, on a different code path.

What I have NOT verified (stated so it isn't overclaimed)

I have not run this against a live Azure AI Search instance. The Azure SDK is faked; Memory and AzureAISearch are the real repo code, and the fake implements the same user_id eq '…' filter the store emits. So the mechanism and the write are observed directly, and the "alice can no longer see it" consequence is demonstrated against documented filter semantics — but two service-side details are inferred rather than observed:

  1. mergeOrUploadDocuments merging by key on a real index (it is documented to merge-or-upload by key, and id is the key field at :186-190).
  2. Which document a real search("*", { top: 1 }) returns. On a real index that is arbitrary; the corruption needs the first hit to be a memory. Note this becomes more likely over time, because getUserId() can insert a phantom { id: <uuid>, user_id: <random> } row into the memories index (:658), which is then eligible to be that first hit.

Suggested fix

Bind a dedicated client for the migrations index in both helpers instead of reusing this.searchClient:

const migrationsClient = new searchSdk.SearchClient(serviceEndpoint, "memory_migrations", credential);

(~15 lines.) A regression test should assert that setUserId()/getUserId() never issue a document write against this.indexName. Happy to open a PR if this is something you want.

AI Assistance

AI helped me find it, and I reproduced it myself afterwards