refactor(telemetry): sample OSS hot-path events at 10% to reduce PostHog volume (#4771)
This commit is contained in:
@@ -14,6 +14,25 @@ try {
|
||||
const POSTHOG_API_KEY = "phc_hgJkUVJFYtmaJqrvf6CYN67TIQ8yhXAkWzUn9AMU4yX";
|
||||
const POSTHOG_HOST = "https://us.i.posthog.com/i/v0/e/";
|
||||
|
||||
// Default sampling rate for hot-path OSS events. Lifecycle events always fire at 100%.
|
||||
// Override via MEM0_TELEMETRY_SAMPLE_RATE env var. Mirrors mem0/memory/telemetry.py.
|
||||
const DEFAULT_SAMPLE_RATE = 0.1;
|
||||
const MEM0_TELEMETRY_SAMPLE_RATE: number = ((): number => {
|
||||
try {
|
||||
const raw = process?.env?.MEM0_TELEMETRY_SAMPLE_RATE;
|
||||
if (raw !== undefined) {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_SAMPLE_RATE;
|
||||
})();
|
||||
|
||||
// Events that bypass sampling. Keep in sync with _captureEvent call sites in memory/index.ts.
|
||||
const LIFECYCLE_EVENTS: ReadonlySet<string> = new Set(["init", "reset"]);
|
||||
|
||||
class UnifiedTelemetry implements TelemetryClient {
|
||||
private apiKey: string;
|
||||
private host: string;
|
||||
@@ -78,6 +97,12 @@ async function captureClientEvent(
|
||||
return;
|
||||
}
|
||||
|
||||
// >= so that rate=0 drops everything and rate=1 keeps everything (Math.random() ∈ [0, 1)).
|
||||
const isLifecycle = LIFECYCLE_EVENTS.has(eventName);
|
||||
if (!isLifecycle && Math.random() >= MEM0_TELEMETRY_SAMPLE_RATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData: TelemetryEventData = {
|
||||
function: `${instance.constructor.name}`,
|
||||
method: eventName,
|
||||
@@ -86,6 +111,8 @@ async function captureClientEvent(
|
||||
client_version: version,
|
||||
client_source: "nodejs",
|
||||
...additionalData,
|
||||
// sample_rate set AFTER the spread so callers can never override it
|
||||
sample_rate: isLifecycle ? 1.0 : MEM0_TELEMETRY_SAMPLE_RATE,
|
||||
};
|
||||
|
||||
await telemetry.captureEvent(
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface TelemetryEventData {
|
||||
timestamp?: string;
|
||||
client_source: "browser" | "nodejs";
|
||||
client_version: string;
|
||||
/** Set by the sampling layer so PostHog dashboards can extrapolate via 1/sample_rate. */
|
||||
sample_rate?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/// <reference types="jest" />
|
||||
/**
|
||||
* Telemetry sampling — unit tests.
|
||||
*
|
||||
* Sampling lives inside captureClientEvent in src/utils/telemetry.ts. It uses
|
||||
* Math.random() compared against MEM0_TELEMETRY_SAMPLE_RATE (default 0.1) to
|
||||
* drop hot-path events; lifecycle events ('init', 'reset') always fire.
|
||||
*
|
||||
* These tests target captureClientEvent directly. Existing OSS tests that mock
|
||||
* captureClientEvent (vector-stores-compat, dimension-autodetect, config-manager)
|
||||
* are unaffected because the function signature and contract are unchanged.
|
||||
*/
|
||||
|
||||
import type { TelemetryInstance } from "../src/utils/telemetry.types";
|
||||
|
||||
// Helper to make a fake TelemetryInstance.
|
||||
function makeInstance(
|
||||
overrides: Partial<TelemetryInstance> = {},
|
||||
): TelemetryInstance {
|
||||
return {
|
||||
telemetryId: "test-id",
|
||||
constructor: { name: "Memory" },
|
||||
host: "https://test.example.com",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("telemetry sampling", () => {
|
||||
let originalFetch: typeof global.fetch;
|
||||
let fetchMock: jest.Mock;
|
||||
let randomSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch;
|
||||
fetchMock = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: jest.fn().mockResolvedValue(""),
|
||||
});
|
||||
global.fetch = fetchMock as any;
|
||||
randomSpy = jest.spyOn(Math, "random");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
randomSpy.mockRestore();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe("lifecycle events always fire", () => {
|
||||
it("init event fires even at the highest random value", async () => {
|
||||
randomSpy.mockReturnValue(0.999);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("init", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reset event fires even at the highest random value", async () => {
|
||||
randomSpy.mockReturnValue(0.999);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("reset", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("init event payload has sample_rate: 1.0", async () => {
|
||||
randomSpy.mockReturnValue(0.999);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("init", makeInstance());
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.properties.sample_rate).toBe(1.0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hot-path events are sampled", () => {
|
||||
it("add event is dropped when random > sample_rate", async () => {
|
||||
randomSpy.mockReturnValue(0.99); // > 0.1 default rate
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("search event is dropped when random > sample_rate", async () => {
|
||||
randomSpy.mockReturnValue(0.99);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("search", makeInstance());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("add event passes when random < sample_rate", async () => {
|
||||
randomSpy.mockReturnValue(0.05); // < 0.1 default rate
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hot-path event payload has sample_rate: 0.1", async () => {
|
||||
randomSpy.mockReturnValue(0.05);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.properties.sample_rate).toBe(0.1);
|
||||
});
|
||||
|
||||
it("sample_rate cannot be overridden by additionalData", async () => {
|
||||
randomSpy.mockReturnValue(0.05);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance(), { sample_rate: 0.99 });
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.properties.sample_rate).toBe(0.1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("env var override", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.MEM0_TELEMETRY_SAMPLE_RATE;
|
||||
});
|
||||
|
||||
it("rate 1.0 sends every event including hot-path at high random", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "1.0";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.999);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
// 0.999 > 1.0 is false, so the gate never trips
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rate 0.0 drops every hot-path event including at random 0", async () => {
|
||||
// The gate is `random >= rate`, so rate=0 drops at random=0 (0 >= 0 is true).
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "0.0";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.0);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate 0.0 drops at any random value", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "0.0";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.5);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rate 0.0 still passes lifecycle events", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "0.0";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.999);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("init", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rate 0.5 passes events at random below 0.5", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "0.5";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.3);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("invalid env var falls back to default rate", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "not a number";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.05);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("out-of-range env var (negative) falls back to default rate", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "-0.5";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.05);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("out-of-range env var (> 1) falls back to default rate", async () => {
|
||||
process.env.MEM0_TELEMETRY_SAMPLE_RATE = "5";
|
||||
jest.resetModules();
|
||||
randomSpy.mockReturnValue(0.05);
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance());
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contract preservation", () => {
|
||||
it("lifecycle event includes additionalData", async () => {
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("init", makeInstance(), {
|
||||
api_version: "v1.1",
|
||||
client_type: "Memory",
|
||||
});
|
||||
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(body.properties.api_version).toBe("v1.1");
|
||||
expect(body.properties.client_type).toBe("Memory");
|
||||
expect(body.properties.sample_rate).toBe(1.0);
|
||||
});
|
||||
|
||||
it("captureClientEvent without telemetryId is a no-op", async () => {
|
||||
const { captureClientEvent } = await import("../src/utils/telemetry");
|
||||
await captureClientEvent("add", makeInstance({ telemetryId: "" }));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user