mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: move telemetry out of core into a usage statistics plugin
This commit is contained in:
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,
|
||||
|
||||
BIN
Binary file not shown.
+2
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user