feat: move telemetry out of core into a usage statistics plugin

This commit is contained in:
LukeGus committed 2026-09-30 23:54:38 -05:00
1 parent f8edb5f299
commit 6217b91ae2
56 files changed
+2064 -564

No files matched your search

@@ -23,9 +23,6 @@ vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: vi.fn(async () => {}),
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../utils/analytics.js", () => ({
getTelemetryEnvOverride: () => null,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings[key] ?? null,
Binary file not shown.
+2
View File
@@ -901,6 +901,8 @@ export const ROWS: Record<string, Row[]> = {
["terminal_session_persistence_enabled", "false"],
["terminal_image_max_count", "7"],
["session_sharing_globally_enabled", "false"],
["analytics_enabled", "false"],
["analytics_instance_id", "d4-instance-id"],
// Long enough that the fixture's recordings never age out.
["session_recording_retention_days", "3650"],
].map(([key, value]) => ({ key, value })),
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../plugins/index.js", () => ({
getPluginRuntime: () => ({
loader: {
list: () => [
{
id: "alpha",
source: "bundled",
state: "active",
manifest: { version: "1.2.0" },
dir: "/secret/path",
lastError: "boom",
},
{
id: "beta",
source: "user",
state: "stopped",
manifest: { version: "0.1.0" },
},
],
},
}),
}));
const { createPluginPlugins } = await import("../../plugins/ctx-plugins.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
const manifest = (capabilities: string[]) =>
({ id: "reader", capabilities }) as unknown as PluginManifest;
describe("ctx.plugins", () => {
it("lists id, version, source and state only", async () => {
const plugins = createPluginPlugins(manifest(["plugins:read"]));
expect(await plugins.list()).toEqual([
{ id: "alpha", version: "1.2.0", source: "bundled", state: "active" },
{ id: "beta", version: "0.1.0", source: "user", state: "stopped" },
]);
});
it("refuses without plugins:read", async () => {
const plugins = createPluginPlugins(manifest([]));
await expect(plugins.list()).rejects.toBeInstanceOf(PluginCapabilityError);
});
});
@@ -543,6 +543,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
globallyEnabled: false,
});
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
enabled: false,
instanceId: "d4-instance-id",
});
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
domain: "termix.d4.example",
email: "d4@example.com",
@@ -0,0 +1,100 @@
/**
* The telemetry settings migration: an instance that turned telemetry off in
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
key: string;
value: string | null;
}
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
let pluginInstalled = true;
const find = (pluginId: string, key: string) =>
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (pluginInstalled ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
find(pluginId, key) ?? null,
set: async (
pluginId: string,
_scope: string,
_id: null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, key);
if (existing) existing.value = value;
else settingsRows.push({ pluginId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
const { runTelemetrySettingsMigration } =
await import("../../upgrade/telemetry-settings-migration.js");
const stored = (key: string) => {
const row = find("telemetry", key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
describe("runTelemetrySettingsMigration", () => {
beforeEach(() => {
coreSettings.clear();
settingsRows.length = 0;
pluginInstalled = true;
});
it("moves a disabled switch and the instance id", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "abc-123");
const result = await runTelemetrySettingsMigration();
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
expect(stored("enabled")).toBe(false);
expect(stored("instanceId")).toBe("abc-123");
});
it("writes nothing for an enabled instance without an id", async () => {
coreSettings.set("analytics_enabled", "true");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
it("is idempotent and never overwrites plugin values", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "old");
settingsRows.push({
pluginId: "telemetry",
key: "instanceId",
value: JSON.stringify("new"),
});
await runTelemetrySettingsMigration();
const second = await runTelemetrySettingsMigration();
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
expect(stored("instanceId")).toBe("new");
expect(settingsRows).toHaveLength(2);
});
it("does nothing without the plugin row", async () => {
pluginInstalled = false;
coreSettings.set("analytics_enabled", "false");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
});
-192
View File
@@ -1,192 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockGetBoolean = vi.fn();
const mockGet = vi.fn();
const mockSet = vi.fn();
const mockPost = vi.fn();
function makeChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {};
const methods = ["from", "where", "groupBy"];
for (const m of methods) {
chain[m] = vi.fn(() => chain);
}
(chain as unknown as Promise<unknown>).then = (cb: (v: unknown) => unknown) =>
Promise.resolve(resolveValue).then(cb);
return chain;
}
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
getBoolean: mockGetBoolean,
get: mockGet,
set: mockSet,
}),
createCurrentRepositoryContext: () => ({
drizzle: {
select: vi.fn(() => makeChain([{ count: 0 }])),
},
}),
}));
vi.mock("../../database/db/schema.js", () => ({
users: {},
hosts: {},
recentActivity: { type: "type", timestamp: "timestamp" },
}));
vi.mock("../../utils/logger.js", () => ({
Logger: class {
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock("axios", () => ({
default: { post: mockPost },
}));
describe("analytics", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.clearAllMocks();
process.env = { ...originalEnv };
delete process.env.ENABLE_TELEMETRY;
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("isAnalyticsEnabled defaults to true via the settings repository", async () => {
delete process.env.ENABLE_TELEMETRY;
mockGetBoolean.mockResolvedValue(true);
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
const result = await isAnalyticsEnabled();
expect(result).toBe(true);
expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true);
});
it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => {
process.env.ENABLE_TELEMETRY = "false";
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
const result = await isAnalyticsEnabled();
expect(result).toBe(false);
expect(mockGetBoolean).not.toHaveBeenCalled();
});
it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => {
process.env.ENABLE_TELEMETRY = "TRUE";
const { isAnalyticsEnabled } = await import("../../utils/analytics.js");
const result = await isAnalyticsEnabled();
expect(result).toBe(true);
expect(mockGetBoolean).not.toHaveBeenCalled();
});
it("getTelemetryEnvOverride returns null when unset or blank", async () => {
const { getTelemetryEnvOverride } =
await import("../../utils/analytics.js");
delete process.env.ENABLE_TELEMETRY;
expect(getTelemetryEnvOverride()).toBe(null);
process.env.ENABLE_TELEMETRY = " ";
expect(getTelemetryEnvOverride()).toBe(null);
});
it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => {
process.env.ENABLE_TELEMETRY = "false";
process.env.POSTHOG_API_KEY = "phc_test";
const { startAnalyticsHeartbeat } =
await import("../../utils/analytics.js");
startAnalyticsHeartbeat();
await Promise.resolve();
expect(mockPost).not.toHaveBeenCalled();
});
it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => {
process.env.ENABLE_TELEMETRY = "false";
process.env.POSTHOG_API_KEY = "phc_test";
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
expect(mockPost).not.toHaveBeenCalled();
});
it("getOrCreateInstanceId returns the existing id without generating one", async () => {
mockGet.mockResolvedValue("existing-id");
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
const id = await getOrCreateInstanceId();
expect(id).toBe("existing-id");
expect(mockSet).not.toHaveBeenCalled();
});
it("getOrCreateInstanceId generates and persists a new id when absent", async () => {
mockGet.mockResolvedValue(null);
const { getOrCreateInstanceId } = await import("../../utils/analytics.js");
const id = await getOrCreateInstanceId();
expect(id).toMatch(/^[0-9a-f-]{36}$/);
expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id);
});
it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => {
delete process.env.POSTHOG_API_KEY;
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
expect(mockPost).not.toHaveBeenCalled();
});
it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => {
process.env.POSTHOG_API_KEY = "phc_test";
mockGetBoolean.mockResolvedValue(false);
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
expect(mockPost).not.toHaveBeenCalled();
});
it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => {
process.env.POSTHOG_API_KEY = "phc_test";
mockGetBoolean.mockResolvedValue(true);
mockGet.mockResolvedValue("instance-123");
mockPost.mockResolvedValue({});
const { collectAndSendHeartbeat } =
await import("../../utils/analytics.js");
await collectAndSendHeartbeat();
expect(mockPost).toHaveBeenCalledTimes(1);
const [url, body] = mockPost.mock.calls[0];
expect(url).toContain("/capture/");
expect(body).toMatchObject({
api_key: "phc_test",
event: "instance_heartbeat",
distinct_id: "instance-123",
properties: expect.objectContaining({
user_count: 0,
host_count: 0,
}),
});
});
});