fix: replace PostHog evaluate_flags with static config for OSS notices (#7185)

This commit is contained in:
Kartik
2026-09-02 18:00:01 +05:30
committed by GitHub
parent c33ca27f5e
commit 3cf41878ea
15 changed files with 643 additions and 468 deletions
+64 -29
View File
@@ -1,11 +1,9 @@
import * as crypto from "crypto";
import * as fs from "fs"; import * as fs from "fs";
import * as os from "os"; import * as os from "os";
import * as path from "path"; import * as path from "path";
import { import { captureNoticeEvent, isTelemetryEnabled } from "./telemetry";
POSTHOG_API_KEY, import bundledNoticeConfig from "./oss_notices_config.json";
captureNoticeEvent,
isTelemetryEnabled,
} from "./telemetry";
import type { TelemetryInstance } from "./telemetry.types"; import type { TelemetryInstance } from "./telemetry.types";
export const NOTICE_FLAG_KEY = "mem0-oss-notices"; export const NOTICE_FLAG_KEY = "mem0-oss-notices";
@@ -21,7 +19,57 @@ export const PERFORMANCE_SLOW_QUERY_NOTICE_ID = "performance_slow_query";
export const NOTICE_CAP_LIMIT = 10; export const NOTICE_CAP_LIMIT = 10;
export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; export const NOTICE_CAP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
export const NOTICE_FLAG_TIMEOUT_MS = 500; export const NOTICE_FLAG_TIMEOUT_MS = 500;
export const POSTHOG_FLAGS_URL = "https://us.i.posthog.com/flags?v=2"; const REMOTE_CONFIG_URL =
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json";
const CONFIG_TTL_MS = 3_600_000;
let _cachedConfig: Record<string, any> | null = null;
let _cachedConfigTs = 0;
let _configFetch: Promise<Record<string, any>> | null = null;
async function fetchNoticeConfig(
fetchImpl: typeof fetch,
timeoutMs: number,
): Promise<Record<string, any>> {
const now = Date.now();
if (_cachedConfig && now - _cachedConfigTs < CONFIG_TTL_MS) {
return _cachedConfig;
}
if (_configFetch) return _configFetch;
_configFetch = (async () => {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetchImpl(REMOTE_CONFIG_URL, {
signal: controller.signal,
});
if (resp.ok) {
_cachedConfig = await resp.json();
}
} finally {
clearTimeout(timeout);
}
} catch {}
_cachedConfig ??= bundledNoticeConfig as Record<string, any>;
_cachedConfigTs = Date.now();
return _cachedConfig;
})();
try {
return await _configFetch;
} finally {
_configFetch = null;
}
}
function resetNoticeConfigCache(): void {
_cachedConfig = null;
_cachedConfigTs = 0;
_configFetch = null;
}
const DISPLAYED_VARIANT = "displayed"; const DISPLAYED_VARIANT = "displayed";
const HOLDOUT_VARIANT = "holdout"; const HOLDOUT_VARIANT = "holdout";
const LOG_LINE_NOTICE_TYPE = "log_line"; const LOG_LINE_NOTICE_TYPE = "log_line";
@@ -255,37 +303,23 @@ export async function evaluateNoticeFlag(
const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS;
const fetchImpl = options.fetchImpl ?? fetch; const fetchImpl = options.fetchImpl ?? fetch;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetchImpl(POSTHOG_FLAGS_URL, { const config = await fetchNoticeConfig(fetchImpl, timeoutMs);
method: "POST", const split =
headers: { "Content-Type": "application/json" }, typeof config.variant_split === "number" ? config.variant_split : 0.5;
body: JSON.stringify({ const hashKey = `${NOTICE_FLAG_KEY}.${distinctId}variant`;
api_key: POSTHOG_API_KEY, const hash = crypto.createHash("sha1").update(hashKey).digest("hex");
distinct_id: distinctId, const bucket = parseInt(hash.substring(0, 15), 16) / 0xfffffffffffffff;
}), const variant = bucket < split ? DISPLAYED_VARIANT : HOLDOUT_VARIANT;
signal: controller.signal,
});
if (!response.ok) return null;
const data: any = await response.json();
const flag = data?.flags?.[NOTICE_FLAG_KEY];
if (!flag || flag.enabled === false) return null;
const variant = typeof flag.variant === "string" ? flag.variant : null;
if (!variant) return null;
return { return {
variant, variant,
payload: parsePayload(flag.metadata?.payload), payload: { notices: config.notices ?? {} },
flag, flag: { key: NOTICE_FLAG_KEY, enabled: true, variant },
}; };
} catch { } catch {
return null; return null;
} finally {
clearTimeout(timeout);
} }
} }
@@ -1408,6 +1442,7 @@ export const __noticeTestHooks = {
appendNoticeCapEvent, appendNoticeCapEvent,
emitNoticeDisplayed, emitNoticeDisplayed,
evaluateNoticeFlag, evaluateNoticeFlag,
resetNoticeConfigCache,
displayFirstRunNotice, displayFirstRunNotice,
displayDecayUsageNotice, displayDecayUsageNotice,
displayTemporalUsageNotice, displayTemporalUsageNotice,
@@ -0,0 +1,44 @@
{
"version": 1,
"variant_split": 0.5,
"notices": {
"first_run": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"temporal_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"temporal_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"decay_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"decay_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"scale_threshold": {
"copies": {
"memory_count": "",
"top_k": ""
},
"enabled": false,
"notice_type": "log_line"
},
"performance_slow_query": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
}
}
}
@@ -57,34 +57,25 @@ function decayPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(decayPayload())
: options.payload,
},
},
},
}), }),
}; };
} }
@@ -222,32 +213,10 @@ describe("Node OSS decay feature error notice", () => {
expect(notices[0].properties.bypass_reason).toBeUndefined(); expect(notices[0].properties.bypass_reason).toBeUndefined();
}); });
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "decay_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: DECAY_COPY,
}),
);
});
it("uses plain error for disabled payload and emits payload_disabled", async () => { it("uses plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "holdout", variant: "holdout",
payload: JSON.stringify(decayPayload({ enabled: false })), payload: decayPayload({ enabled: false }),
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -270,17 +239,8 @@ describe("Node OSS decay feature error notice", () => {
}); });
it.each([ it.each([
[ ["missing config", { notices: {} }, "missing_notice_config"],
"missing config", ["missing copy", decayPayload({ copy: "" }), "missing_copy"],
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(decayPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("uses plain error for %s", async (_label, payload, bypassReason) => { ])("uses plain error for %s", async (_label, payload, bypassReason) => {
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "displayed", variant: "displayed",
@@ -304,11 +264,8 @@ describe("Node OSS decay feature error notice", () => {
); );
}); });
it("uses plain error and emits no event when the blunt flag is disabled", async () => { it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({ failConfig: true });
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -316,19 +273,14 @@ describe("Node OSS decay feature error notice", () => {
PLAIN_DECAY_ERROR, PLAIN_DECAY_ERROR,
); );
expect(noticeEvents(calls)).toHaveLength(0); const notices = noticeEvents(calls);
}); expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
it("uses plain error and emits no event when PostHog fails", async () => { expect.objectContaining({
const { fetchMock, calls } = createFetchMock({ failFlags: true }); notice_id: "decay_stub",
global.fetch = fetchMock as any; displayed: false,
const memory = await createMemory(); }),
await expect(memory.updateProject({ decay: true })).rejects.toThrow(
PLAIN_DECAY_ERROR,
); );
expect(noticeEvents(calls)).toHaveLength(0);
}); });
it("uses plain error and skips flag evaluation when telemetry is off", async () => { it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -361,7 +313,9 @@ describe("Node OSS decay feature error notice", () => {
); );
expect( expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")), fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
).toHaveLength(0); ).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
}); });
@@ -81,33 +81,25 @@ function decayUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? decayUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
const payload =
options.payload === undefined
? JSON.stringify(decayUsagePayload())
: options.payload;
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}), }),
}; };
} }
@@ -134,9 +126,10 @@ function noticeEvents(calls: any[]) {
return calls.filter((call) => call.event === "mem0.notice_displayed"); return calls.filter((call) => call.event === "mem0.notice_displayed");
} }
function flagRequestCount(fetchMock: jest.Mock) { function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) return fetchMock.mock.calls.filter(([url]) =>
.length; String(url).includes("raw.githubusercontent.com"),
).length;
} }
async function createMemory() { async function createMemory() {
@@ -226,7 +219,7 @@ describe("Node OSS decay usage notice", () => {
await memory.delete(id); await memory.delete(id);
} }
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined(); expect(readConfig().notice_state?.decay_usage).toBeUndefined();
expect(stderrSpy).not.toHaveBeenCalledWith( expect(stderrSpy).not.toHaveBeenCalledWith(
@@ -279,7 +272,7 @@ describe("Node OSS decay usage notice", () => {
await expect(memory.delete("memory-id")).rejects.toThrow("delete failed"); await expect(memory.delete("memory-id")).rejects.toThrow("delete failed");
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined(); expect(readConfig().notice_state?.decay_usage).toBeUndefined();
}); });
@@ -317,7 +310,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "empty-delete-all-user" }); await memory.deleteAll({ userId: "empty-delete-all-user" });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined(); expect(readConfig().notice_state?.decay_usage).toBeUndefined();
}); });
@@ -332,7 +325,7 @@ describe("Node OSS decay usage notice", () => {
"At least one filter is required", "At least one filter is required",
); );
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined(); expect(readConfig().notice_state?.decay_usage).toBeUndefined();
}); });
@@ -365,27 +358,21 @@ describe("Node OSS decay usage notice", () => {
it.each([ it.each([
[ [
"disabled payload", "disabled payload",
JSON.stringify(decayUsagePayload({ enabled: false })), decayUsagePayload({ enabled: false }),
"payload_disabled", "payload_disabled",
"payload_disabled", "payload_disabled",
], ],
[ ["missing config", { notices: {} }, "missing_notice_config", undefined],
"missing config",
JSON.stringify({ notices: {} }),
"missing_notice_config",
undefined,
],
[ [
"missing copy", "missing copy",
JSON.stringify({ {
notices: { notices: {
decay_usage: { enabled: true, notice_type: "log_line" }, decay_usage: { enabled: true, notice_type: "log_line" },
}, },
}), },
"missing_copy", "missing_copy",
undefined, undefined,
], ],
["malformed payload", "{not-json", "missing_notice_config", undefined],
])( ])(
"stays silent and emits safe bypass for %s", "stays silent and emits safe bypass for %s",
async (_label, payload, bypassReason, disabledReason) => { async (_label, payload, bypassReason, disabledReason) => {
@@ -416,33 +403,23 @@ describe("Node OSS decay usage notice", () => {
}, },
); );
it("does not emit or consume cap when the blunt flag is disabled", async () => { it("emits not-displayed event when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({ failConfig: true });
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
await addMemories(memory, "decay-flag-disabled-user", 3); await addMemories(memory, "decay-config-failure-user", 3);
await memory.deleteAll({ userId: "decay-flag-disabled-user" }); await memory.deleteAll({ userId: "decay-config-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0); const notices = noticeEvents(calls);
expect(readConfig().notice_state?.decay_usage).toBeUndefined(); expect(notices).toHaveLength(1);
}); expect(notices[0].properties).toEqual(
expect.objectContaining({
it("does not emit or consume cap when PostHog fails", async () => { notice_id: "decay_usage",
consumeFirstRun(); displayed: false,
const { fetchMock, calls } = createFetchMock({ failFlags: true }); }),
global.fetch = fetchMock as any; );
const memory = await createMemory();
await addMemories(memory, "decay-posthog-failure-user", 3);
await memory.deleteAll({ userId: "decay-posthog-failure-user" });
expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
}); });
it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => { it("skips flag evaluation, event emission, and state writes when telemetry is off", async () => {
@@ -484,7 +461,7 @@ describe("Node OSS decay usage notice", () => {
await memory.deleteAll({ userId: "decay-cap-user" }); await memory.deleteAll({ userId: "decay-cap-user" });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10); expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
}); });
@@ -493,7 +470,7 @@ describe("Node OSS decay usage notice", () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "displayed", variant: "displayed",
payload: JSON.stringify({ payload: {
notices: { notices: {
first_run: { first_run: {
enabled: true, enabled: true,
@@ -506,7 +483,7 @@ describe("Node OSS decay usage notice", () => {
copy: DECAY_USAGE_COPY, copy: DECAY_USAGE_COPY,
}, },
}, },
}), },
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
+23 -19
View File
@@ -57,29 +57,24 @@ function firstRunPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: Record<string, any>; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? firstRunPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: true,
variant: options.variant ?? "displayed",
metadata: {
payload: JSON.stringify(options.payload ?? firstRunPayload()),
},
},
},
}), }),
}; };
} }
@@ -376,23 +371,32 @@ describe("Node OSS first-run notice", () => {
); );
expect( expect(
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")), fetchMock.mock.calls.filter(([url]) =>
String(url).includes("raw.githubusercontent.com"),
),
).toHaveLength(0); ).toHaveLength(0);
expect(noticeEvents(calls)).toHaveLength(0); expect(noticeEvents(calls)).toHaveLength(0);
}); });
it("does not break the successful operation when flag evaluation fails", async () => { it("does not break the successful operation when config fetch fails", async () => {
const { fetchMock, calls } = createFetchMock({ failFlags: true }); const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
const result = await memory.add("Flag failure content", { const result = await memory.add("Config failure content", {
userId: "first-run-failure", userId: "first-run-failure",
infer: false, infer: false,
}); });
expect(result.results).toHaveLength(1); expect(result.results).toHaveLength(1);
expect(noticeEvents(calls)).toHaveLength(0); const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "first_run",
displayed: false,
}),
);
}); });
it("can trigger from get() when get is the first successful public call", async () => { it("can trigger from get() when get is the first successful public call", async () => {
@@ -138,16 +138,49 @@ describe("Node OSS notice foundation", () => {
expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false); expect(fs.existsSync(notices.getMem0ConfigPath())).toBe(false);
}); });
it("returns null when PostHog flag evaluation fails", async () => { it("falls back to bundled config when remote fetch fails", async () => {
const notices = await import("../src/utils/notices"); const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockRejectedValue(new Error("network down")); const fetchMock = jest.fn().mockRejectedValue(new Error("network down"));
await expect( const firstResult = await notices.evaluateNoticeFlag("notice-test-user", {
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }), fetchImpl: fetchMock,
).resolves.toBeNull(); });
const secondResult = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock,
});
expect(firstResult).not.toBeNull();
expect(secondResult?.variant).toBe(firstResult?.variant);
expect(fetchMock).toHaveBeenCalledTimes(1);
}); });
it("returns null when PostHog flag evaluation times out", async () => { it("coalesces concurrent remote config fetches", async () => {
const notices = await import("../src/utils/notices");
const resolvers: Array<(response: any) => void> = [];
const fetchMock = jest.fn(
() => new Promise((resolve) => resolvers.push(resolve)),
);
const evaluations = [
notices.evaluateNoticeFlag("user-0", { fetchImpl: fetchMock as any }),
notices.evaluateNoticeFlag("user-1", { fetchImpl: fetchMock as any }),
];
resolvers.forEach((resolve) =>
resolve({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
}),
);
await Promise.all(evaluations);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("falls back to bundled config when remote fetch times out", async () => {
const notices = await import("../src/utils/notices"); const notices = await import("../src/utils/notices");
const fetchMock = jest.fn( const fetchMock = jest.fn(
(_url: string | URL | Request, init?: RequestInit) => (_url: string | URL | Request, init?: RequestInit) =>
@@ -158,34 +191,28 @@ describe("Node OSS notice foundation", () => {
}), }),
); );
await expect( const startedAt = Date.now();
notices.evaluateNoticeFlag("notice-test-user", { const result = await notices.evaluateNoticeFlag("notice-test-user", {
fetchImpl: fetchMock as any, fetchImpl: fetchMock as any,
timeoutMs: 1, timeoutMs: 10,
}), });
).resolves.toBeNull(); expect(result).not.toBeNull();
expect(result?.variant).toMatch(/^(displayed|holdout)$/);
expect(Date.now() - startedAt).toBeLessThan(500);
}); });
it("parses displayed variant and JSON payload from PostHog flags response", async () => { it("parses variant and notices from static config response", async () => {
const notices = await import("../src/utils/notices"); const notices = await import("../src/utils/notices");
const payload = {
notices: {
foundation_notice: {
enabled: true,
notice_type: "log_line",
copy: "Foundation notice",
},
},
};
const fetchMock = jest.fn().mockResolvedValue({ const fetchMock = jest.fn().mockResolvedValue({
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: 1.0,
key: "mem0-oss-notices", notices: {
foundation_notice: {
enabled: true, enabled: true,
variant: "displayed", notice_type: "log_line",
metadata: { payload: JSON.stringify(payload) }, copy: "Foundation notice",
}, },
}, },
}), }),
@@ -203,6 +230,27 @@ describe("Node OSS notice foundation", () => {
expect(parsed.config?.copy).toBe("Foundation notice"); expect(parsed.config?.copy).toBe("Foundation notice");
}); });
it.each([
["user-0", "holdout"],
["user-1", "displayed"],
])("preserves the PostHog cohort for %s", async (distinctId, expected) => {
const notices = await import("../src/utils/notices");
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue({
version: 1,
variant_split: 0.5,
notices: {},
}),
});
const result = await notices.evaluateNoticeFlag(distinctId, {
fetchImpl: fetchMock,
});
expect(result?.variant).toBe(expected);
});
it("emits mem0.notice_displayed with sample_rate=1", async () => { it("emits mem0.notice_displayed with sample_rate=1", async () => {
const fetchMock = jest.fn().mockResolvedValue({ const fetchMock = jest.fn().mockResolvedValue({
ok: true, ok: true,
@@ -81,33 +81,25 @@ function performancePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? performancePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
const payload =
options.payload === undefined
? JSON.stringify(performancePayload())
: options.payload;
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}), }),
}; };
} }
@@ -140,9 +132,10 @@ function performanceEvents(calls: any[]) {
); );
} }
function flagRequestCount(fetchMock: jest.Mock) { function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) return fetchMock.mock.calls.filter(([url]) =>
.length; String(url).includes("raw.githubusercontent.com"),
).length;
} }
function mockSearchElapsed(elapsedMs: number) { function mockSearchElapsed(elapsedMs: number) {
@@ -294,20 +287,11 @@ describe("Node OSS performance slow query notice", () => {
it.each([ it.each([
[ [
"disabled payload", "disabled payload",
JSON.stringify(performancePayload({ enabled: false })), performancePayload({ enabled: false }),
"payload_disabled", "payload_disabled",
], ],
[ ["missing config", { notices: {} }, "missing_notice_config"],
"missing config", ["missing copy", performancePayload({ copy: "" }), "missing_copy"],
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(performancePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => { ])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
@@ -347,7 +331,7 @@ describe("Node OSS performance slow query notice", () => {
topK: 3, topK: 3,
}); });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0); expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
}); });
@@ -366,17 +350,14 @@ describe("Node OSS performance slow query notice", () => {
}), }),
).rejects.toThrow("filters must contain"); ).rejects.toThrow("filters must contain");
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(performanceEvents(calls)).toHaveLength(0); expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
}); });
it("does not consume cap or emit when the blunt flag is disabled", async () => { it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({ failConfig: true });
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
await addSeed(memory); await addSeed(memory);
@@ -387,25 +368,14 @@ describe("Node OSS performance slow query notice", () => {
topK: 3, topK: 3,
}); });
expect(performanceEvents(calls)).toHaveLength(0); const notices = performanceEvents(calls);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined(); expect(notices).toHaveLength(1);
}); expect(notices[0].properties).toEqual(
expect.objectContaining({
it("does not consume cap or emit when PostHog fails", async () => { notice_id: "performance_slow_query",
consumeFirstRun(); displayed: false,
const { fetchMock, calls } = createFetchMock({ failFlags: true }); }),
global.fetch = fetchMock as any; );
const memory = await createMemory();
await addSeed(memory);
mockSearchElapsed(2345);
await memory.search("favorite drink", {
filters: { user_id: "performance-user" },
topK: 3,
});
expect(performanceEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
}); });
it("does nothing when telemetry is off", async () => { it("does nothing when telemetry is off", async () => {
@@ -444,7 +414,7 @@ describe("Node OSS performance slow query notice", () => {
} }
expect(performanceEvents(calls)).toHaveLength(10); expect(performanceEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10); expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(stderrSpy).toHaveBeenCalledTimes(10); expect(stderrSpy).toHaveBeenCalledTimes(10);
expect( expect(
readConfig().notice_state.performance_slow_query.events, readConfig().notice_state.performance_slow_query.events,
@@ -87,33 +87,25 @@ function scalePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? scalePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
const payload =
options.payload === undefined
? JSON.stringify(scalePayload())
: options.payload;
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}), }),
}; };
} }
@@ -146,9 +138,10 @@ function scaleEvents(calls: any[]) {
); );
} }
function flagRequestCount(fetchMock: jest.Mock) { function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) return fetchMock.mock.calls.filter(([url]) =>
.length; String(url).includes("raw.githubusercontent.com"),
).length;
} }
async function createMemory() { async function createMemory() {
@@ -290,14 +283,14 @@ describe("Node OSS scale threshold notice", () => {
topK: 49, topK: 49,
}); });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0); expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.scale_threshold).toBeUndefined(); expect(readConfig().notice_state?.scale_threshold).toBeUndefined();
}); });
it("marks memory-count threshold evaluated before PostHog display", async () => { it("marks memory-count threshold evaluated even when config fetch fails", async () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ failFlags: true }); const { fetchMock, calls } = createFetchMock({ failConfig: true });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
(memory as any).vectorStore.count = jest (memory as any).vectorStore.count = jest
@@ -314,7 +307,14 @@ describe("Node OSS scale threshold notice", () => {
readConfig().notice_state.scale_threshold readConfig().notice_state.scale_threshold
.memory_count_threshold_evaluated, .memory_count_threshold_evaluated,
).toBe(true); ).toBe(true);
expect(scaleEvents(calls)).toHaveLength(0); const notices = scaleEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "scale_threshold",
displayed: false,
}),
);
}); });
it("does not count provider memories once threshold was evaluated", async () => { it("does not count provider memories once threshold was evaluated", async () => {
@@ -332,7 +332,7 @@ describe("Node OSS scale threshold notice", () => {
}); });
expect((memory as any).vectorStore.count).not.toHaveBeenCalled(); expect((memory as any).vectorStore.count).not.toHaveBeenCalled();
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
}); });
it("throttles under-threshold provider counts", async () => { it("throttles under-threshold provider counts", async () => {
@@ -354,14 +354,14 @@ describe("Node OSS scale threshold notice", () => {
}); });
expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1); expect((memory as any).vectorStore.count).toHaveBeenCalledTimes(1);
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
}); });
it("records holdout and disabled variants silently", async () => { it("records holdout and disabled variants silently", async () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "holdout", variant: "holdout",
payload: JSON.stringify(scalePayload({ enabled: false })), payload: scalePayload({ enabled: false }),
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -389,7 +389,7 @@ describe("Node OSS scale threshold notice", () => {
delete (payload.notices.scale_threshold as Record<string, any>).enabled; delete (payload.notices.scale_threshold as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "displayed", variant: "displayed",
payload: JSON.stringify(payload), payload,
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -425,7 +425,7 @@ describe("Node OSS scale threshold notice", () => {
} }
expect(scaleEvents(calls)).toHaveLength(10); expect(scaleEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10); expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(stderrSpy).toHaveBeenCalledTimes(10); expect(stderrSpy).toHaveBeenCalledTimes(10);
expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10); expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10);
}); });
@@ -456,7 +456,7 @@ describe("Node OSS scale threshold notice", () => {
topK: 50, topK: 50,
}); });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(scaleEvents(calls)).toHaveLength(0); expect(scaleEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state).toBeUndefined(); expect(readConfig().notice_state).toBeUndefined();
}); });
@@ -61,34 +61,25 @@ function temporalPayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalPayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: {
payload:
options.payload === undefined
? JSON.stringify(temporalPayload())
: options.payload,
},
},
},
}), }),
}; };
} }
@@ -265,37 +256,12 @@ describe("Node OSS temporal feature error notice", () => {
}, },
); );
it("uses plain error for unknown future variants and emits not_displayed", async () => {
const { fetchMock, calls } = createFetchMock({ variant: "silent" });
global.fetch = fetchMock as any;
const memory = await createMemory();
await expect(
memory.add("Temporal add", {
userId: "temporal-user",
timestamp: 1778112000,
}),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
const notices = noticeEvents(calls);
expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
expect.objectContaining({
notice_id: "temporal_stub",
variant: "silent",
displayed: false,
bypass_reason: "not_displayed",
payload: TEMPORAL_COPY,
}),
);
});
it("treats missing enabled as enabled for feature-error payloads", async () => { it("treats missing enabled as enabled for feature-error payloads", async () => {
const payload = temporalPayload(); const payload = temporalPayload();
delete (payload.notices.temporal_stub as Record<string, any>).enabled; delete (payload.notices.temporal_stub as Record<string, any>).enabled;
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "displayed", variant: "displayed",
payload: JSON.stringify(payload), payload,
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -319,7 +285,7 @@ describe("Node OSS temporal feature error notice", () => {
it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => { it("uses timestamp plain error for disabled payload and emits payload_disabled", async () => {
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
variant: "displayed", variant: "displayed",
payload: JSON.stringify(temporalPayload({ enabled: false })), payload: temporalPayload({ enabled: false }),
}); });
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -347,17 +313,8 @@ describe("Node OSS temporal feature error notice", () => {
}); });
it.each([ it.each([
[ ["missing config", { notices: {} }, "missing_notice_config"],
"missing config", ["missing copy", temporalPayload({ copy: "" }), "missing_copy"],
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalPayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])( ])(
"uses referenceDate plain error for %s", "uses referenceDate plain error for %s",
async (_label, payload, bypassReason) => { async (_label, payload, bypassReason) => {
@@ -389,11 +346,8 @@ describe("Node OSS temporal feature error notice", () => {
}, },
); );
it("uses plain error and emits no event when the blunt flag is disabled", async () => { it("uses plain error when config fetch fails (bundled config fallback)", async () => {
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({ failConfig: true });
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -404,22 +358,14 @@ describe("Node OSS temporal feature error notice", () => {
}), }),
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR); ).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
expect(noticeEvents(calls)).toHaveLength(0); const notices = noticeEvents(calls);
}); expect(notices).toHaveLength(1);
expect(notices[0].properties).toEqual(
it("uses plain error and emits no event when PostHog fails", async () => { expect.objectContaining({
const { fetchMock, calls } = createFetchMock({ failFlags: true }); notice_id: "temporal_stub",
global.fetch = fetchMock as any; displayed: false,
const memory = await createMemory();
await expect(
memory.search("Temporal search", {
filters: { user_id: "temporal-user" },
referenceDate: "2026-05-06",
}), }),
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR); );
expect(noticeEvents(calls)).toHaveLength(0);
}); });
it("uses plain error and skips flag evaluation when telemetry is off", async () => { it("uses plain error and skips flag evaluation when telemetry is off", async () => {
@@ -81,33 +81,25 @@ function temporalUsagePayload(overrides: Record<string, any> = {}) {
function createFetchMock(options: { function createFetchMock(options: {
variant?: string; variant?: string;
payload?: unknown; payload?: Record<string, any>;
failFlags?: boolean; failConfig?: boolean;
flagEnabled?: boolean;
}) { }) {
const calls: any[] = []; const calls: any[] = [];
const variantSplit = options.variant === "holdout" ? 0.0 : 1.0;
const payload = options.payload ?? temporalUsagePayload();
const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => { const fetchMock = jest.fn(async (url: string | URL, init?: RequestInit) => {
const target = String(url); const target = String(url);
if (target.includes("/flags")) { if (target.includes("raw.githubusercontent.com")) {
if (options.failFlags) { if (options.failConfig) {
throw new Error("flag evaluation failed"); throw new Error("config fetch failed");
} }
const payload =
options.payload === undefined
? JSON.stringify(temporalUsagePayload())
: options.payload;
return { return {
ok: true, ok: true,
json: jest.fn().mockResolvedValue({ json: jest.fn().mockResolvedValue({
flags: { version: 1,
"mem0-oss-notices": { variant_split: variantSplit,
key: "mem0-oss-notices", ...payload,
enabled: options.flagEnabled ?? true,
variant: options.variant ?? "displayed",
metadata: { payload },
},
},
}), }),
}; };
} }
@@ -140,9 +132,10 @@ function temporalUsageEvents(calls: any[]) {
); );
} }
function flagRequestCount(fetchMock: jest.Mock) { function configRequestCount(fetchMock: jest.Mock) {
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")) return fetchMock.mock.calls.filter(([url]) =>
.length; String(url).includes("raw.githubusercontent.com"),
).length;
} }
async function createMemory() { async function createMemory() {
@@ -320,7 +313,7 @@ describe("Node OSS temporal usage notice", () => {
topK: 3, topK: 3,
}); });
expect(flagRequestCount(fetchMock)).toBe(0); expect(configRequestCount(fetchMock)).toBe(0);
expect(temporalUsageEvents(calls)).toHaveLength(0); expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
}); });
@@ -377,20 +370,11 @@ describe("Node OSS temporal usage notice", () => {
it.each([ it.each([
[ [
"disabled payload", "disabled payload",
JSON.stringify(temporalUsagePayload({ enabled: false })), temporalUsagePayload({ enabled: false }),
"payload_disabled", "payload_disabled",
], ],
[ ["missing config", { notices: {} }, "missing_notice_config"],
"missing config", ["missing copy", temporalUsagePayload({ copy: "" }), "missing_copy"],
JSON.stringify({ notices: {} }),
"missing_notice_config",
],
[
"missing copy",
JSON.stringify(temporalUsagePayload({ copy: "" })),
"missing_copy",
],
["malformed payload", "{not-json", "missing_notice_config"],
])("is silent and safe for %s", async (_label, payload, bypassReason) => { ])("is silent and safe for %s", async (_label, payload, bypassReason) => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({
@@ -418,12 +402,9 @@ describe("Node OSS temporal usage notice", () => {
); );
}); });
it("does not consume cap or emit when the blunt flag is disabled", async () => { it("emits not-displayed when config fetch fails (bundled config fallback)", async () => {
consumeFirstRun(); consumeFirstRun();
const { fetchMock, calls } = createFetchMock({ const { fetchMock, calls } = createFetchMock({ failConfig: true });
variant: "displayed",
flagEnabled: false,
});
global.fetch = fetchMock as any; global.fetch = fetchMock as any;
const memory = await createMemory(); const memory = await createMemory();
@@ -433,24 +414,14 @@ describe("Node OSS temporal usage notice", () => {
metadata: { event_date: "2025-04-09" }, metadata: { event_date: "2025-04-09" },
}); });
expect(temporalUsageEvents(calls)).toHaveLength(0); const notices = temporalUsageEvents(calls);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined(); expect(notices).toHaveLength(1);
}); expect(notices[0].properties).toEqual(
expect.objectContaining({
it("does not consume cap or emit when PostHog fails", async () => { notice_id: "temporal_usage",
consumeFirstRun(); displayed: false,
const { fetchMock, calls } = createFetchMock({ failFlags: true }); }),
global.fetch = fetchMock as any; );
const memory = await createMemory();
await memory.add("Temporal metadata memory", {
userId: "temporal-user",
infer: false,
metadata: { event_date: "2025-04-09" },
});
expect(temporalUsageEvents(calls)).toHaveLength(0);
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
}); });
it("does nothing when telemetry is off", async () => { it("does nothing when telemetry is off", async () => {
@@ -487,7 +458,7 @@ describe("Node OSS temporal usage notice", () => {
} }
expect(temporalUsageEvents(calls)).toHaveLength(10); expect(temporalUsageEvents(calls)).toHaveLength(10);
expect(flagRequestCount(fetchMock)).toBe(10); expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10); expect(readConfig().notice_state.temporal_usage.events).toHaveLength(10);
expect(stderrSpy).toHaveBeenCalledTimes(10); expect(stderrSpy).toHaveBeenCalledTimes(10);
}); });
+97 -12
View File
@@ -1,16 +1,101 @@
import asyncio import asyncio
import hashlib
import json import json
import logging
import os
import re import re
import sys import sys
import threading import threading
import time
import urllib.error
import urllib.request
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
from mem0.memory import telemetry as telemetry_module from mem0.memory import telemetry as telemetry_module
from mem0.memory.setup import _load_config, _write_config from mem0.memory.setup import _load_config, _write_config
_logger = logging.getLogger(__name__)
FLAG_KEY = "mem0-oss-notices" FLAG_KEY = "mem0-oss-notices"
_REMOTE_CONFIG_URL = (
"https://raw.githubusercontent.com/mem0ai/mem0/main/mem0/memory/oss_notices_config.json"
)
_BUNDLED_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "oss_notices_config.json")
_CONFIG_TTL_SECONDS = 3600
_CONFIG_FETCH_TIMEOUT_SECONDS = telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS
_cached_config = None
_cached_config_ts = 0.0
_config_fetch_lock = threading.Lock()
class StaticFlagResult:
"""Drop-in replacement for PostHog's evaluate_flags return value."""
def __init__(self, variant, payload):
self._variant = variant
self._payload = payload
def get_flag(self, key):
return self._variant
def get_flag_payload(self, key):
return self._payload
def _get_event_properties(self):
return {
f"$feature/{FLAG_KEY}": self._variant,
"$active_feature_flags": [FLAG_KEY],
}
def _load_bundled_config():
try:
with open(_BUNDLED_CONFIG_PATH) as f:
return json.load(f)
except Exception:
return {"version": 1, "variant_split": 0.5, "notices": {}}
def _fetch_remote_config():
try:
req = urllib.request.Request(_REMOTE_CONFIG_URL, headers={"User-Agent": "mem0-oss"})
with urllib.request.urlopen(req, timeout=_CONFIG_FETCH_TIMEOUT_SECONDS) as resp:
return json.loads(resp.read())
except Exception:
_logger.debug("Failed to fetch remote notice config, using bundled fallback")
return None
def _get_notice_config():
global _cached_config, _cached_config_ts
now = time.monotonic()
if _cached_config is not None and (now - _cached_config_ts) < _CONFIG_TTL_SECONDS:
return _cached_config
with _config_fetch_lock:
if _cached_config is not None and (now - _cached_config_ts) < _CONFIG_TTL_SECONDS:
return _cached_config
remote = _fetch_remote_config()
if remote is not None and isinstance(remote, dict) and "notices" in remote:
_cached_config = remote
else:
_cached_config = _load_bundled_config()
_cached_config_ts = time.monotonic()
return _cached_config
def _evaluate_notice_flags(user_id):
config = _get_notice_config()
split = config.get("variant_split", 0.5)
hash_key = f"{FLAG_KEY}.{user_id}variant"
bucket = int(hashlib.sha1(hash_key.encode()).hexdigest()[:15], 16) / float(0xFFFFFFFFFFFFFFF)
variant = DISPLAYED_VARIANT if bucket < split else HOLDOUT_VARIANT
payload = {"notices": config.get("notices", {})}
return StaticFlagResult(variant, payload)
NOTICE_ID = "first_run" NOTICE_ID = "first_run"
TEMPORAL_FEATURE_NOTICE_ID = "temporal_stub" TEMPORAL_FEATURE_NOTICE_ID = "temporal_stub"
TEMPORAL_USAGE_NOTICE_ID = "temporal_usage" TEMPORAL_USAGE_NOTICE_ID = "temporal_usage"
@@ -88,10 +173,10 @@ def display_first_run_notice(memory_instance, sync_type: str, trigger_function:
variant = None variant = None
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return return
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
_update_first_run_variant(variant) _update_first_run_variant(variant)
@@ -168,10 +253,10 @@ def display_temporal_usage_notice(
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return return
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
if variant in (None, False): if variant in (None, False):
return return
@@ -302,10 +387,10 @@ def display_decay_usage_notice(
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return return
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
if variant in (None, False): if variant in (None, False):
return return
@@ -480,10 +565,10 @@ def display_scale_threshold_notice(
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return return
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
if variant in (None, False): if variant in (None, False):
return return
@@ -596,10 +681,10 @@ def display_performance_slow_query_notice(
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return return
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
if variant in (None, False): if variant in (None, False):
return return
@@ -749,10 +834,10 @@ def _get_feature_error_message(
try: try:
telemetry = telemetry_module._get_oss_telemetry() telemetry = telemetry_module._get_oss_telemetry()
if telemetry is None or telemetry.posthog is None or not telemetry.user_id: if telemetry is None or not telemetry.user_id:
return plain_error return plain_error
flags = telemetry.posthog.evaluate_flags(telemetry.user_id, flag_keys=[FLAG_KEY]) flags = _evaluate_notice_flags(telemetry.user_id)
variant = flags.get_flag(FLAG_KEY) variant = flags.get_flag(FLAG_KEY)
if variant in (None, False): if variant in (None, False):
return plain_error return plain_error
+44
View File
@@ -0,0 +1,44 @@
{
"version": 1,
"variant_split": 0.5,
"notices": {
"first_run": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"temporal_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"temporal_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"decay_stub": {
"copy": "",
"enabled": false,
"notice_type": "error"
},
"decay_usage": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
},
"scale_threshold": {
"copies": {
"memory_count": "",
"top_k": ""
},
"enabled": false,
"notice_type": "log_line"
},
"performance_slow_query": {
"copy": "",
"enabled": false,
"notice_type": "log_line"
}
}
}
+2
View File
@@ -92,10 +92,12 @@ pythonpath = ["."]
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
"mem0/**/*.py", "mem0/**/*.py",
"mem0/memory/oss_notices_config.json",
] ]
exclude = [ exclude = [
"**/*", "**/*",
"!mem0/**/*.py", "!mem0/**/*.py",
"!mem0/memory/oss_notices_config.json",
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
+113 -21
View File
@@ -56,6 +56,7 @@ def notice_harness(monkeypatch):
config = {} config = {}
telemetry = MagicMock() telemetry = MagicMock()
telemetry.user_id = "oss-user" telemetry.user_id = "oss-user"
evaluate_mock = MagicMock()
def write_config(updated): def write_config(updated):
saved = deepcopy(updated) saved = deepcopy(updated)
@@ -66,13 +67,15 @@ def notice_harness(monkeypatch):
monkeypatch.setattr(notices, "_write_config", write_config) monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True) monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry) monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
telemetry._evaluate_mock = evaluate_mock
return config, telemetry return config, telemetry
def configure_flag(telemetry, variant, payload): def configure_flag(telemetry, variant, payload):
flags = FakeFlags(variant, payload) flags = FakeFlags(variant, payload)
telemetry.posthog.evaluate_flags.return_value = flags telemetry._evaluate_mock.return_value = flags
return flags return flags
@@ -176,7 +179,7 @@ def test_displayed_notice_logs_once_and_captures_event(notice_harness, capsys):
config, telemetry, flags = display_notice(notice_harness) config, telemetry, flags = display_notice(notice_harness)
assert capsys.readouterr().err == "Mem0 OSS notice\n" assert capsys.readouterr().err == "Mem0 OSS notice\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -254,7 +257,7 @@ def test_telemetry_disabled_does_not_touch_posthog_or_state(monkeypatch, capsys)
def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys): def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys):
config, telemetry = notice_harness config, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
notices.display_first_run_notice(MagicMock(), "sync", "add") notices.display_first_run_notice(MagicMock(), "sync", "add")
@@ -265,7 +268,7 @@ def test_posthog_failure_is_silent_and_consumes_first_run(notice_harness, capsys
def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness): def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
_, telemetry = notice_harness _, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
memory = Memory.__new__(Memory) memory = Memory.__new__(Memory)
memory.config = SimpleNamespace(llm=SimpleNamespace(config={})) memory.config = SimpleNamespace(llm=SimpleNamespace(config={}))
memory._add_to_vector_store = MagicMock(return_value=[{"event": "ADD", "memory": "likes tea"}]) memory._add_to_vector_store = MagicMock(return_value=[{"event": "ADD", "memory": "likes tea"}])
@@ -277,7 +280,7 @@ def test_public_add_succeeds_when_first_run_flag_eval_fails(notice_harness):
def test_public_search_succeeds_when_first_run_flag_eval_fails(notice_harness, monkeypatch): def test_public_search_succeeds_when_first_run_flag_eval_fails(notice_harness, monkeypatch):
_, telemetry = notice_harness _, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
monkeypatch.setattr(memory_main, "capture_event", MagicMock()) monkeypatch.setattr(memory_main, "capture_event", MagicMock())
memory = Memory.__new__(Memory) memory = Memory.__new__(Memory)
memory.api_version = "v1.1" memory.api_version = "v1.1"
@@ -325,7 +328,7 @@ def test_temporal_feature_displayed_returns_payload_copy_and_captures_event(noti
assert message == "Temporal CTA" assert message == "Temporal CTA"
assert capsys.readouterr().err == "" assert capsys.readouterr().err == ""
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -413,7 +416,7 @@ def test_temporal_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch,
def test_temporal_feature_posthog_failure_returns_plain_error(notice_harness, capsys): def test_temporal_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
_, telemetry = notice_harness _, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
@@ -429,14 +432,14 @@ def test_temporal_feature_cap_blocks_repeated_posthog_evaluation(notice_harness)
for _ in range(notices.FEATURE_ERROR_CAP): for _ in range(notices.FEATURE_ERROR_CAP):
assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA" assert notices.get_temporal_feature_error_message("sync", "add", "timestamp") == "Temporal CTA"
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP assert len(config["notice_state"]["temporal_stub"]["events"]) == notices.FEATURE_ERROR_CAP
message = notices.get_temporal_feature_error_message("sync", "add", "timestamp") message = notices.get_temporal_feature_error_message("sync", "add", "timestamp")
assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"] assert message == notices.TEMPORAL_FEATURE_ERROR_MESSAGES["timestamp"]
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
@@ -465,7 +468,7 @@ def test_decay_feature_displayed_returns_payload_copy_and_captures_event(notice_
assert message == "Decay CTA" assert message == "Decay CTA"
assert capsys.readouterr().err == "" assert capsys.readouterr().err == ""
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -552,7 +555,7 @@ def test_decay_feature_telemetry_disabled_does_not_touch_posthog(monkeypatch, ca
def test_decay_feature_posthog_failure_returns_plain_error(notice_harness, capsys): def test_decay_feature_posthog_failure_returns_plain_error(notice_harness, capsys):
_, telemetry = notice_harness _, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
message = notices.get_decay_feature_error_message("sync", "project.update", "decay") message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
@@ -582,14 +585,14 @@ def test_decay_feature_cap_blocks_repeated_posthog_evaluation(notice_harness):
for _ in range(notices.FEATURE_ERROR_CAP): for _ in range(notices.FEATURE_ERROR_CAP):
assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA" assert notices.get_decay_feature_error_message("sync", "project.update", "decay") == "Decay CTA"
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
assert len(config["notice_state"]["decay_stub"]["events"]) == notices.FEATURE_ERROR_CAP assert len(config["notice_state"]["decay_stub"]["events"]) == notices.FEATURE_ERROR_CAP
message = notices.get_decay_feature_error_message("sync", "project.update", "decay") message = notices.get_decay_feature_error_message("sync", "project.update", "decay")
assert message == notices.DECAY_FEATURE_ERROR_MESSAGE assert message == notices.DECAY_FEATURE_ERROR_MESSAGE
assert telemetry.posthog.evaluate_flags.call_count == notices.FEATURE_ERROR_CAP assert telemetry._evaluate_mock.call_count == notices.FEATURE_ERROR_CAP
assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP assert telemetry.capture_event.call_count == notices.FEATURE_ERROR_CAP
@@ -670,7 +673,7 @@ def test_temporal_usage_displayed_logs_and_captures_event(notice_harness, capsys
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
assert capsys.readouterr().err == "Temporal usage CTA\n" assert capsys.readouterr().err == "Temporal usage CTA\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -771,7 +774,7 @@ def test_temporal_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase") notices.display_temporal_usage_notice(MagicMock(), "sync", "search", "query", "relative_phrase")
assert capsys.readouterr().err == "Temporal usage CTA\n" * notices.TEMPORAL_USAGE_CAP assert capsys.readouterr().err == "Temporal usage CTA\n" * notices.TEMPORAL_USAGE_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.TEMPORAL_USAGE_CAP assert telemetry._evaluate_mock.call_count == notices.TEMPORAL_USAGE_CAP
assert telemetry.capture_event.call_count == notices.TEMPORAL_USAGE_CAP assert telemetry.capture_event.call_count == notices.TEMPORAL_USAGE_CAP
assert len(config["notice_state"]["temporal_usage"]["events"]) == notices.TEMPORAL_USAGE_CAP assert len(config["notice_state"]["temporal_usage"]["events"]) == notices.TEMPORAL_USAGE_CAP
@@ -869,7 +872,7 @@ def test_decay_usage_displayed_logs_and_captures_event(notice_harness, capsys):
) )
assert capsys.readouterr().err == "Decay usage CTA\n" assert capsys.readouterr().err == "Decay usage CTA\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -994,7 +997,7 @@ def test_decay_usage_telemetry_disabled_does_not_touch_posthog_or_state(monkeypa
def test_decay_usage_posthog_failure_does_not_consume_cap(notice_harness, capsys): def test_decay_usage_posthog_failure_does_not_consume_cap(notice_harness, capsys):
config, telemetry = notice_harness config, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("posthog down") telemetry._evaluate_mock.side_effect = RuntimeError("posthog down")
notices.display_decay_usage_notice( notices.display_decay_usage_notice(
MagicMock(), MagicMock(),
@@ -1034,7 +1037,7 @@ def test_decay_usage_cap_blocks_before_posthog_eval(notice_harness, capsys):
) )
assert capsys.readouterr().err == "Decay usage CTA\n" * notices.DECAY_USAGE_CAP assert capsys.readouterr().err == "Decay usage CTA\n" * notices.DECAY_USAGE_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.DECAY_USAGE_CAP assert telemetry._evaluate_mock.call_count == notices.DECAY_USAGE_CAP
assert telemetry.capture_event.call_count == notices.DECAY_USAGE_CAP assert telemetry.capture_event.call_count == notices.DECAY_USAGE_CAP
assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP assert len(config["notice_state"]["decay_usage"]["events"]) == notices.DECAY_USAGE_CAP
@@ -1111,7 +1114,7 @@ def test_scale_threshold_displayed_logs_and_captures_event(notice_harness, capsy
) )
assert capsys.readouterr().err == "Scale top 50\n" assert capsys.readouterr().err == "Scale top 50\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -1238,7 +1241,7 @@ def test_scale_threshold_telemetry_disabled_does_not_touch_posthog_or_state(monk
def test_scale_threshold_posthog_failure_does_not_consume_cap(notice_harness, capsys): def test_scale_threshold_posthog_failure_does_not_consume_cap(notice_harness, capsys):
config, telemetry = notice_harness config, telemetry = notice_harness
telemetry.posthog.evaluate_flags.side_effect = RuntimeError("network unavailable") telemetry._evaluate_mock.side_effect = RuntimeError("network unavailable")
notices.display_scale_threshold_notice( notices.display_scale_threshold_notice(
MagicMock(), MagicMock(),
@@ -1281,7 +1284,7 @@ def test_scale_threshold_cap_blocks_before_posthog_eval(notice_harness, capsys):
) )
assert capsys.readouterr().err == "Scale top 50\n" * notices.SCALE_THRESHOLD_CAP assert capsys.readouterr().err == "Scale top 50\n" * notices.SCALE_THRESHOLD_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.SCALE_THRESHOLD_CAP assert telemetry._evaluate_mock.call_count == notices.SCALE_THRESHOLD_CAP
assert telemetry.capture_event.call_count == notices.SCALE_THRESHOLD_CAP assert telemetry.capture_event.call_count == notices.SCALE_THRESHOLD_CAP
assert len(config["notice_state"]["scale_threshold"]["events"]) == notices.SCALE_THRESHOLD_CAP assert len(config["notice_state"]["scale_threshold"]["events"]) == notices.SCALE_THRESHOLD_CAP
@@ -1549,3 +1552,92 @@ def test_notice_priority_scale_beats_first_run(monkeypatch):
) )
assert calls == ["scale"] assert calls == ["scale"]
class TestStaticFlagEvaluation:
def test_static_flag_result_exposes_posthog_event_properties(self):
flags = notices.StaticFlagResult(notices.DISPLAYED_VARIANT, {"notices": {}})
assert flags._get_event_properties() == {
f"$feature/{notices.FLAG_KEY}": notices.DISPLAYED_VARIANT,
"$active_feature_flags": [notices.FLAG_KEY],
}
def test_evaluate_notice_flags_returns_displayed_or_holdout(self):
result = notices._evaluate_notice_flags("user-abc")
variant = result.get_flag(notices.FLAG_KEY)
assert variant in (notices.DISPLAYED_VARIANT, notices.HOLDOUT_VARIANT)
def test_evaluate_notice_flags_deterministic(self):
a = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
b = notices._evaluate_notice_flags("user-123").get_flag(notices.FLAG_KEY)
assert a == b
@pytest.mark.parametrize(
("user_id", "expected_variant"),
[("user-0", notices.HOLDOUT_VARIANT), ("user-1", notices.DISPLAYED_VARIANT)],
)
def test_evaluate_notice_flags_preserves_posthog_cohorts(self, monkeypatch, user_id, expected_variant):
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.5, "notices": {}})
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
assert notices._evaluate_notice_flags(user_id).get_flag(notices.FLAG_KEY) == expected_variant
def test_evaluate_notice_flags_returns_payload_with_notices(self):
result = notices._evaluate_notice_flags("user-abc")
payload = result.get_flag_payload(notices.FLAG_KEY)
assert "notices" in payload
def test_get_notice_config_uses_bundled_fallback(self, monkeypatch):
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: None)
config = notices._get_notice_config()
assert "notices" in config
assert config.get("version") == 1
def test_remote_config_fetch_uses_telemetry_timeout(self, monkeypatch):
timeouts = []
def fail_fetch(_request, timeout):
timeouts.append(timeout)
raise TimeoutError
monkeypatch.setattr(notices.urllib.request, "urlopen", fail_fetch)
assert notices._fetch_remote_config() is None
assert timeouts == [telemetry_module.FEATURE_FLAGS_REQUEST_TIMEOUT_SECONDS]
def test_get_notice_config_prefers_remote(self, monkeypatch):
remote = {"version": 2, "variant_split": 0.5, "notices": {"first_run": {"copy": "remote", "enabled": True}}}
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", lambda: remote)
config = notices._get_notice_config()
assert config["version"] == 2
def test_get_notice_config_caches_result(self, monkeypatch):
call_count = 0
remote = {"version": 3, "variant_split": 0.5, "notices": {}}
def counting_fetch():
nonlocal call_count
call_count += 1
return remote
monkeypatch.setattr(notices, "_cached_config", None)
monkeypatch.setattr(notices, "_cached_config_ts", 0.0)
monkeypatch.setattr(notices, "_fetch_remote_config", counting_fetch)
notices._get_notice_config()
notices._get_notice_config()
assert call_count == 1
def test_variant_split_respects_config(self, monkeypatch):
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 1.0, "notices": {}})
monkeypatch.setattr(notices, "_cached_config_ts", float("inf"))
result = notices._evaluate_notice_flags("any-user")
assert result.get_flag(notices.FLAG_KEY) == notices.DISPLAYED_VARIANT
monkeypatch.setattr(notices, "_cached_config", {"variant_split": 0.0, "notices": {}})
result = notices._evaluate_notice_flags("any-user")
assert result.get_flag(notices.FLAG_KEY) == notices.HOLDOUT_VARIANT
@@ -3,8 +3,8 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from mem0.memory import notices
from mem0.memory import main as memory_main from mem0.memory import main as memory_main
from mem0.memory import notices
from mem0.memory.main import AsyncMemory, Memory from mem0.memory.main import AsyncMemory, Memory
@@ -34,6 +34,7 @@ def notice_harness(monkeypatch):
config = {} config = {}
telemetry = MagicMock() telemetry = MagicMock()
telemetry.user_id = "oss-user" telemetry.user_id = "oss-user"
evaluate_mock = MagicMock()
def write_config(updated): def write_config(updated):
saved = deepcopy(updated) saved = deepcopy(updated)
@@ -44,12 +45,14 @@ def notice_harness(monkeypatch):
monkeypatch.setattr(notices, "_write_config", write_config) monkeypatch.setattr(notices, "_write_config", write_config)
monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True) monkeypatch.setattr(notices.telemetry_module, "MEM0_TELEMETRY", True)
monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry) monkeypatch.setattr(notices.telemetry_module, "_get_oss_telemetry", lambda: telemetry)
monkeypatch.setattr(notices, "_evaluate_notice_flags", evaluate_mock)
telemetry._evaluate_mock = evaluate_mock
return config, telemetry return config, telemetry
def configure_flag(telemetry, variant, payload): def configure_flag(telemetry, variant, payload):
flags = FakeFlags(variant, payload) flags = FakeFlags(variant, payload)
telemetry.posthog.evaluate_flags.return_value = flags telemetry._evaluate_mock.return_value = flags
return flags return flags
@@ -274,7 +277,7 @@ def test_performance_slow_query_displayed_logs_and_captures_event(notice_harness
) )
assert capsys.readouterr().err == "Performance CTA\n" assert capsys.readouterr().err == "Performance CTA\n"
telemetry.posthog.evaluate_flags.assert_called_once_with("oss-user", flag_keys=[notices.FLAG_KEY]) telemetry._evaluate_mock.assert_called_once_with("oss-user")
telemetry.capture_event.assert_called_once() telemetry.capture_event.assert_called_once()
event_name, props = telemetry.capture_event.call_args.args event_name, props = telemetry.capture_event.call_args.args
assert event_name == notices.NOTICE_EVENT assert event_name == notices.NOTICE_EVENT
@@ -421,7 +424,7 @@ def test_performance_slow_query_cap_blocks_before_posthog_eval(notice_harness, c
) )
assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP assert capsys.readouterr().err == "Performance CTA\n" * notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.posthog.evaluate_flags.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP assert telemetry._evaluate_mock.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP assert telemetry.capture_event.call_count == notices.PERFORMANCE_SLOW_QUERY_CAP
assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP assert len(config["notice_state"]["performance_slow_query"]["events"]) == notices.PERFORMANCE_SLOW_QUERY_CAP