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
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_idfilter, so it disappears from theirsearch()/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 frommemory_migrationscollection" — andmemory_migrationsis created at:638and 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-386asserts anINSERT INTO memory_migrationswithbinds = { user_id: userId }. So "write the migrations store, not the memory store" is the established contract, not an interpretation. azure-ai-searchis a documented TypeScript vector store —docs/open-source/configuration.mdx:124lists it in the TypeScript column alongsidememory,qdrant,pgvector,redis,supabase,vectorize,milvus— and it has no tests at all inmem0-ts/src/oss/tests/(onlyazure-embedder.test.tsexists).user_idis 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, andgetAll/searchsimply stop returning a memory the user successfully wrote.- Disabling telemetry does not prevent it.
MEM0_TELEMETRYis checked insideUnifiedTelemetry.captureEvent(utils/telemetry.ts:56), which is called after_getTelemetryId()in_captureEvent(memory/index.ts:621-624). The vector-store write happens first, soMEM0_TELEMETRY=falsesuppresses the network event and nothing else. The repro above runs with itfalse.
What I Ruled Out
- Not a mock artifact. The mock only stands in for
@azure/search-documents;MemoryandAzureAISearchare unmodified repo code, and the index name in the recorded call (mem0) comes fromthis.indexNamein the real constructor. - The
memory_migrationsindex is created, just unused.getUserId()does callindexClient.createOrUpdateIndex(migrationIndex)at:638; the reads and writes that follow usethis.searchClientinstead. So this is not "the migrations index is missing". searchClientreally is the memory client, not a second client bound to migrations — there is exactly onenew SearchClientininitialize()(:122-126) and it is constructed withthis.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 returnsnullwhen 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 writableMEM0_DIRspecifically so the cached id would be used. With an unwritableMEM0_DIRthe 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:
mergeOrUploadDocumentsmerging by key on a real index (it is documented to merge-or-upload by key, andidis the key field at:186-190).- 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, becausegetUserId()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
Source: mem0ai/mem0