fix: replace PostHog evaluate_flags with static config for OSS notices (#7185)
This commit is contained in:
@@ -1,11 +1,9 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import {
|
||||
POSTHOG_API_KEY,
|
||||
captureNoticeEvent,
|
||||
isTelemetryEnabled,
|
||||
} from "./telemetry";
|
||||
import { captureNoticeEvent, isTelemetryEnabled } from "./telemetry";
|
||||
import bundledNoticeConfig from "./oss_notices_config.json";
|
||||
import type { TelemetryInstance } from "./telemetry.types";
|
||||
|
||||
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_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
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 HOLDOUT_VARIANT = "holdout";
|
||||
const LOG_LINE_NOTICE_TYPE = "log_line";
|
||||
@@ -255,37 +303,23 @@ export async function evaluateNoticeFlag(
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? NOTICE_FLAG_TIMEOUT_MS;
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(POSTHOG_FLAGS_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: POSTHOG_API_KEY,
|
||||
distinct_id: distinctId,
|
||||
}),
|
||||
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;
|
||||
const config = await fetchNoticeConfig(fetchImpl, timeoutMs);
|
||||
const split =
|
||||
typeof config.variant_split === "number" ? config.variant_split : 0.5;
|
||||
const hashKey = `${NOTICE_FLAG_KEY}.${distinctId}variant`;
|
||||
const hash = crypto.createHash("sha1").update(hashKey).digest("hex");
|
||||
const bucket = parseInt(hash.substring(0, 15), 16) / 0xfffffffffffffff;
|
||||
const variant = bucket < split ? DISPLAYED_VARIANT : HOLDOUT_VARIANT;
|
||||
|
||||
return {
|
||||
variant,
|
||||
payload: parsePayload(flag.metadata?.payload),
|
||||
flag,
|
||||
payload: { notices: config.notices ?? {} },
|
||||
flag: { key: NOTICE_FLAG_KEY, enabled: true, variant },
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,6 +1442,7 @@ export const __noticeTestHooks = {
|
||||
appendNoticeCapEvent,
|
||||
emitNoticeDisplayed,
|
||||
evaluateNoticeFlag,
|
||||
resetNoticeConfigCache,
|
||||
displayFirstRunNotice,
|
||||
displayDecayUsageNotice,
|
||||
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: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: {
|
||||
payload:
|
||||
options.payload === undefined
|
||||
? JSON.stringify(decayPayload())
|
||||
: options.payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -222,32 +213,10 @@ describe("Node OSS decay feature error notice", () => {
|
||||
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 () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "holdout",
|
||||
payload: JSON.stringify(decayPayload({ enabled: false })),
|
||||
payload: decayPayload({ enabled: false }),
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
@@ -270,17 +239,8 @@ describe("Node OSS decay feature error notice", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify(decayPayload({ copy: "" })),
|
||||
"missing_copy",
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config"],
|
||||
["missing config", { notices: {} }, "missing_notice_config"],
|
||||
["missing copy", decayPayload({ copy: "" }), "missing_copy"],
|
||||
])("uses plain error for %s", async (_label, payload, bypassReason) => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
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 () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -316,19 +273,14 @@ describe("Node OSS decay feature error notice", () => {
|
||||
PLAIN_DECAY_ERROR,
|
||||
);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses plain error and emits no event when PostHog fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
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",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
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(
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -81,33 +81,25 @@ function decayUsagePayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
const payload =
|
||||
options.payload === undefined
|
||||
? JSON.stringify(decayUsagePayload())
|
||||
: options.payload;
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: { payload },
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -134,9 +126,10 @@ function noticeEvents(calls: any[]) {
|
||||
return calls.filter((call) => call.event === "mem0.notice_displayed");
|
||||
}
|
||||
|
||||
function flagRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
|
||||
.length;
|
||||
function configRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
).length;
|
||||
}
|
||||
|
||||
async function createMemory() {
|
||||
@@ -226,7 +219,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
await memory.delete(id);
|
||||
}
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
expect(stderrSpy).not.toHaveBeenCalledWith(
|
||||
@@ -279,7 +272,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
|
||||
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(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -317,7 +310,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
|
||||
await memory.deleteAll({ userId: "empty-delete-all-user" });
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -332,7 +325,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
"At least one filter is required",
|
||||
);
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
@@ -365,27 +358,21 @@ describe("Node OSS decay usage notice", () => {
|
||||
it.each([
|
||||
[
|
||||
"disabled payload",
|
||||
JSON.stringify(decayUsagePayload({ enabled: false })),
|
||||
decayUsagePayload({ enabled: false }),
|
||||
"payload_disabled",
|
||||
"payload_disabled",
|
||||
],
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
undefined,
|
||||
],
|
||||
["missing config", { notices: {} }, "missing_notice_config", undefined],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify({
|
||||
{
|
||||
notices: {
|
||||
decay_usage: { enabled: true, notice_type: "log_line" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
"missing_copy",
|
||||
undefined,
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config", undefined],
|
||||
])(
|
||||
"stays silent and emits safe bypass for %s",
|
||||
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();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
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);
|
||||
expect(readConfig().notice_state?.decay_usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not emit or consume cap when PostHog fails", async () => {
|
||||
consumeFirstRun();
|
||||
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();
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "decay_usage",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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" });
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state.decay_usage.events).toHaveLength(10);
|
||||
});
|
||||
@@ -493,7 +470,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
payload: JSON.stringify({
|
||||
payload: {
|
||||
notices: {
|
||||
first_run: {
|
||||
enabled: true,
|
||||
@@ -506,7 +483,7 @@ describe("Node OSS decay usage notice", () => {
|
||||
copy: DECAY_USAGE_COPY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -57,29 +57,24 @@ function firstRunPayload(overrides: Record<string, any> = {}) {
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: Record<string, any>;
|
||||
failFlags?: boolean;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: {
|
||||
payload: JSON.stringify(options.payload ?? firstRunPayload()),
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -376,23 +371,32 @@ describe("Node OSS first-run notice", () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags")),
|
||||
fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not break the successful operation when flag evaluation fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
it("does not break the successful operation when config fetch fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
const result = await memory.add("Flag failure content", {
|
||||
const result = await memory.add("Config failure content", {
|
||||
userId: "first-run-failure",
|
||||
infer: false,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -138,16 +138,49 @@ describe("Node OSS notice foundation", () => {
|
||||
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 fetchMock = jest.fn().mockRejectedValue(new Error("network down"));
|
||||
|
||||
await expect(
|
||||
notices.evaluateNoticeFlag("notice-test-user", { fetchImpl: fetchMock }),
|
||||
).resolves.toBeNull();
|
||||
const firstResult = await notices.evaluateNoticeFlag("notice-test-user", {
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
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 fetchMock = jest.fn(
|
||||
(_url: string | URL | Request, init?: RequestInit) =>
|
||||
@@ -158,34 +191,28 @@ describe("Node OSS notice foundation", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
notices.evaluateNoticeFlag("notice-test-user", {
|
||||
fetchImpl: fetchMock as any,
|
||||
timeoutMs: 1,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
const startedAt = Date.now();
|
||||
const result = await notices.evaluateNoticeFlag("notice-test-user", {
|
||||
fetchImpl: fetchMock as any,
|
||||
timeoutMs: 10,
|
||||
});
|
||||
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 payload = {
|
||||
notices: {
|
||||
foundation_notice: {
|
||||
enabled: true,
|
||||
notice_type: "log_line",
|
||||
copy: "Foundation notice",
|
||||
},
|
||||
},
|
||||
};
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
version: 1,
|
||||
variant_split: 1.0,
|
||||
notices: {
|
||||
foundation_notice: {
|
||||
enabled: true,
|
||||
variant: "displayed",
|
||||
metadata: { payload: JSON.stringify(payload) },
|
||||
notice_type: "log_line",
|
||||
copy: "Foundation notice",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -203,6 +230,27 @@ describe("Node OSS notice foundation", () => {
|
||||
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 () => {
|
||||
const fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -81,33 +81,25 @@ function performancePayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
const payload =
|
||||
options.payload === undefined
|
||||
? JSON.stringify(performancePayload())
|
||||
: options.payload;
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: { payload },
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -140,9 +132,10 @@ function performanceEvents(calls: any[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function flagRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
|
||||
.length;
|
||||
function configRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
).length;
|
||||
}
|
||||
|
||||
function mockSearchElapsed(elapsedMs: number) {
|
||||
@@ -294,20 +287,11 @@ describe("Node OSS performance slow query notice", () => {
|
||||
it.each([
|
||||
[
|
||||
"disabled payload",
|
||||
JSON.stringify(performancePayload({ enabled: false })),
|
||||
performancePayload({ enabled: false }),
|
||||
"payload_disabled",
|
||||
],
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify(performancePayload({ copy: "" })),
|
||||
"missing_copy",
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config"],
|
||||
["missing config", { notices: {} }, "missing_notice_config"],
|
||||
["missing copy", performancePayload({ copy: "" }), "missing_copy"],
|
||||
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
@@ -347,7 +331,7 @@ describe("Node OSS performance slow query notice", () => {
|
||||
topK: 3,
|
||||
});
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(performanceEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
|
||||
});
|
||||
@@ -366,17 +350,14 @@ describe("Node OSS performance slow query notice", () => {
|
||||
}),
|
||||
).rejects.toThrow("filters must contain");
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(performanceEvents(calls)).toHaveLength(0);
|
||||
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();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
await addSeed(memory);
|
||||
@@ -387,25 +368,14 @@ describe("Node OSS performance slow query notice", () => {
|
||||
topK: 3,
|
||||
});
|
||||
|
||||
expect(performanceEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.performance_slow_query).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not consume cap or emit when PostHog fails", async () => {
|
||||
consumeFirstRun();
|
||||
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();
|
||||
const notices = performanceEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "performance_slow_query",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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(flagRequestCount(fetchMock)).toBe(10);
|
||||
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
|
||||
expect(stderrSpy).toHaveBeenCalledTimes(10);
|
||||
expect(
|
||||
readConfig().notice_state.performance_slow_query.events,
|
||||
|
||||
@@ -87,33 +87,25 @@ function scalePayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
const payload =
|
||||
options.payload === undefined
|
||||
? JSON.stringify(scalePayload())
|
||||
: options.payload;
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: { payload },
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -146,9 +138,10 @@ function scaleEvents(calls: any[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function flagRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
|
||||
.length;
|
||||
function configRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
).length;
|
||||
}
|
||||
|
||||
async function createMemory() {
|
||||
@@ -290,14 +283,14 @@ describe("Node OSS scale threshold notice", () => {
|
||||
topK: 49,
|
||||
});
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(scaleEvents(calls)).toHaveLength(0);
|
||||
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();
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
(memory as any).vectorStore.count = jest
|
||||
@@ -314,7 +307,14 @@ describe("Node OSS scale threshold notice", () => {
|
||||
readConfig().notice_state.scale_threshold
|
||||
.memory_count_threshold_evaluated,
|
||||
).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 () => {
|
||||
@@ -332,7 +332,7 @@ describe("Node OSS scale threshold notice", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
@@ -354,14 +354,14 @@ describe("Node OSS scale threshold notice", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
consumeFirstRun();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "holdout",
|
||||
payload: JSON.stringify(scalePayload({ enabled: false })),
|
||||
payload: scalePayload({ enabled: false }),
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
@@ -389,7 +389,7 @@ describe("Node OSS scale threshold notice", () => {
|
||||
delete (payload.notices.scale_threshold as Record<string, any>).enabled;
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
payload: JSON.stringify(payload),
|
||||
payload,
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
@@ -425,7 +425,7 @@ describe("Node OSS scale threshold notice", () => {
|
||||
}
|
||||
|
||||
expect(scaleEvents(calls)).toHaveLength(10);
|
||||
expect(flagRequestCount(fetchMock)).toBe(10);
|
||||
expect(configRequestCount(fetchMock)).toBeLessThanOrEqual(1);
|
||||
expect(stderrSpy).toHaveBeenCalledTimes(10);
|
||||
expect(readConfig().notice_state.scale_threshold.events).toHaveLength(10);
|
||||
});
|
||||
@@ -456,7 +456,7 @@ describe("Node OSS scale threshold notice", () => {
|
||||
topK: 50,
|
||||
});
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(scaleEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -61,34 +61,25 @@ function temporalPayload(overrides: Record<string, any> = {}) {
|
||||
|
||||
function createFetchMock(options: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: {
|
||||
payload:
|
||||
options.payload === undefined
|
||||
? JSON.stringify(temporalPayload())
|
||||
: options.payload,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...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 () => {
|
||||
const payload = temporalPayload();
|
||||
delete (payload.notices.temporal_stub as Record<string, any>).enabled;
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
payload: JSON.stringify(payload),
|
||||
payload,
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
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 () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
payload: JSON.stringify(temporalPayload({ enabled: false })),
|
||||
payload: temporalPayload({ enabled: false }),
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
@@ -347,17 +313,8 @@ describe("Node OSS temporal feature error notice", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify(temporalPayload({ copy: "" })),
|
||||
"missing_copy",
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config"],
|
||||
["missing config", { notices: {} }, "missing_notice_config"],
|
||||
["missing copy", temporalPayload({ copy: "" }), "missing_copy"],
|
||||
])(
|
||||
"uses referenceDate plain error for %s",
|
||||
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 () => {
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
it("uses plain error when config fetch fails (bundled config fallback)", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -404,22 +358,14 @@ describe("Node OSS temporal feature error notice", () => {
|
||||
}),
|
||||
).rejects.toThrow(PLAIN_TIMESTAMP_ERROR);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uses plain error and emits no event when PostHog fails", async () => {
|
||||
const { fetchMock, calls } = createFetchMock({ failFlags: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
await expect(
|
||||
memory.search("Temporal search", {
|
||||
filters: { user_id: "temporal-user" },
|
||||
referenceDate: "2026-05-06",
|
||||
const notices = noticeEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "temporal_stub",
|
||||
displayed: false,
|
||||
}),
|
||||
).rejects.toThrow(PLAIN_REFERENCE_DATE_ERROR);
|
||||
|
||||
expect(noticeEvents(calls)).toHaveLength(0);
|
||||
);
|
||||
});
|
||||
|
||||
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: {
|
||||
variant?: string;
|
||||
payload?: unknown;
|
||||
failFlags?: boolean;
|
||||
flagEnabled?: boolean;
|
||||
payload?: Record<string, any>;
|
||||
failConfig?: boolean;
|
||||
}) {
|
||||
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 target = String(url);
|
||||
|
||||
if (target.includes("/flags")) {
|
||||
if (options.failFlags) {
|
||||
throw new Error("flag evaluation failed");
|
||||
if (target.includes("raw.githubusercontent.com")) {
|
||||
if (options.failConfig) {
|
||||
throw new Error("config fetch failed");
|
||||
}
|
||||
const payload =
|
||||
options.payload === undefined
|
||||
? JSON.stringify(temporalUsagePayload())
|
||||
: options.payload;
|
||||
return {
|
||||
ok: true,
|
||||
json: jest.fn().mockResolvedValue({
|
||||
flags: {
|
||||
"mem0-oss-notices": {
|
||||
key: "mem0-oss-notices",
|
||||
enabled: options.flagEnabled ?? true,
|
||||
variant: options.variant ?? "displayed",
|
||||
metadata: { payload },
|
||||
},
|
||||
},
|
||||
version: 1,
|
||||
variant_split: variantSplit,
|
||||
...payload,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -140,9 +132,10 @@ function temporalUsageEvents(calls: any[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function flagRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) => String(url).includes("/flags"))
|
||||
.length;
|
||||
function configRequestCount(fetchMock: jest.Mock) {
|
||||
return fetchMock.mock.calls.filter(([url]) =>
|
||||
String(url).includes("raw.githubusercontent.com"),
|
||||
).length;
|
||||
}
|
||||
|
||||
async function createMemory() {
|
||||
@@ -320,7 +313,7 @@ describe("Node OSS temporal usage notice", () => {
|
||||
topK: 3,
|
||||
});
|
||||
|
||||
expect(flagRequestCount(fetchMock)).toBe(0);
|
||||
expect(configRequestCount(fetchMock)).toBe(0);
|
||||
expect(temporalUsageEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
|
||||
});
|
||||
@@ -377,20 +370,11 @@ describe("Node OSS temporal usage notice", () => {
|
||||
it.each([
|
||||
[
|
||||
"disabled payload",
|
||||
JSON.stringify(temporalUsagePayload({ enabled: false })),
|
||||
temporalUsagePayload({ enabled: false }),
|
||||
"payload_disabled",
|
||||
],
|
||||
[
|
||||
"missing config",
|
||||
JSON.stringify({ notices: {} }),
|
||||
"missing_notice_config",
|
||||
],
|
||||
[
|
||||
"missing copy",
|
||||
JSON.stringify(temporalUsagePayload({ copy: "" })),
|
||||
"missing_copy",
|
||||
],
|
||||
["malformed payload", "{not-json", "missing_notice_config"],
|
||||
["missing config", { notices: {} }, "missing_notice_config"],
|
||||
["missing copy", temporalUsagePayload({ copy: "" }), "missing_copy"],
|
||||
])("is silent and safe for %s", async (_label, payload, bypassReason) => {
|
||||
consumeFirstRun();
|
||||
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();
|
||||
const { fetchMock, calls } = createFetchMock({
|
||||
variant: "displayed",
|
||||
flagEnabled: false,
|
||||
});
|
||||
const { fetchMock, calls } = createFetchMock({ failConfig: true });
|
||||
global.fetch = fetchMock as any;
|
||||
const memory = await createMemory();
|
||||
|
||||
@@ -433,24 +414,14 @@ describe("Node OSS temporal usage notice", () => {
|
||||
metadata: { event_date: "2025-04-09" },
|
||||
});
|
||||
|
||||
expect(temporalUsageEvents(calls)).toHaveLength(0);
|
||||
expect(readConfig().notice_state?.temporal_usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not consume cap or emit when PostHog fails", async () => {
|
||||
consumeFirstRun();
|
||||
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();
|
||||
const notices = temporalUsageEvents(calls);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0].properties).toEqual(
|
||||
expect.objectContaining({
|
||||
notice_id: "temporal_usage",
|
||||
displayed: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does nothing when telemetry is off", async () => {
|
||||
@@ -487,7 +458,7 @@ describe("Node OSS temporal usage notice", () => {
|
||||
}
|
||||
|
||||
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(stderrSpy).toHaveBeenCalledTimes(10);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user