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

@@ -0,0 +1,378 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activateWith } from "../../src/backend/index.js";
import {
DEFAULT_POSTHOG_API_KEY,
envOverride,
posthogConfig,
} from "../../src/backend/config.js";
import { detectDeployment } from "../../src/backend/collect.js";
import {
MAX_FEATURES,
sanitizeUsage,
usageProperty,
} from "../../src/backend/usage.js";
import { CHECK_INTERVAL_MS } from "../../src/backend/reporter.js";
const manifest = manifestJson as unknown as PluginManifest;
const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
interface Sent {
url: string;
body: Record<string, unknown> & { properties: Record<string, unknown> };
}
let cleanup: Array<() => Promise<void> | void> = [];
afterEach(async () => {
for (const fn of cleanup) await fn();
cleanup = [];
});
async function setup(
options: {
settings?: Record<string, unknown>;
env?: Record<string, string | undefined>;
permissions?: string[];
status?: number;
} = {},
) {
const db: TestDb = await createTestDb(pluginDir);
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("u1", "u1");
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run("u2", "u2");
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(1);
const sent: Sent[] = [];
let router: Router | null = null;
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
settings: options.settings,
permissions: options.permissions ?? ["manage"],
installedPlugins: [
{ id: "telemetry", version: "1.0.0", source: "bundled", state: "active" },
{ id: "docker", version: "1.0.0", source: "bundled", state: "active" },
{ id: "serial", version: "1.0.0", source: "bundled", state: "stopped" },
],
fetch: async (url, init) => {
sent.push({ url, body: JSON.parse(String(init?.body)) });
return new Response("{}", { status: options.status ?? 200 });
},
});
mock.setActor("u1");
const env = { VERSION: "2.9.0", ...options.env };
const { reporter, usage } = await activateWith(mock.ctx, env);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? "u1");
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
cleanup.push(
() => new Promise<void>((resolve) => server.close(() => resolve())),
() => db.close(),
);
const call = async (
method: string,
path: string,
body?: unknown,
user = "u1",
) => {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
};
return { mock, reporter, usage, sent, call };
}
describe("config", () => {
it("reads ENABLE_TELEMETRY as a lock only when set", () => {
expect(envOverride({})).toBeNull();
expect(envOverride({ ENABLE_TELEMETRY: " " })).toBeNull();
expect(envOverride({ ENABLE_TELEMETRY: "TRUE" })).toBe(true);
expect(envOverride({ ENABLE_TELEMETRY: "false" })).toBe(false);
expect(envOverride({ ENABLE_TELEMETRY: "no" })).toBe(false);
});
it("uses the embedded key unless the env sets one", () => {
expect(posthogConfig({})).toEqual({
apiKey: DEFAULT_POSTHOG_API_KEY,
host: "https://us.i.posthog.com",
});
expect(
posthogConfig({
POSTHOG_API_KEY: "k",
POSTHOG_HOST: "https://ph.local/",
}),
).toEqual({ apiKey: "k", host: "https://ph.local" });
});
it("detects how Termix runs", () => {
expect(detectDeployment({ ELECTRON_EMBEDDED: "true" }, () => true)).toBe(
"desktop",
);
expect(detectDeployment({}, () => true)).toBe("docker");
expect(detectDeployment({}, () => false)).toBe("server");
});
});
describe("usage counts", () => {
it("keeps valid names and positive whole counts", () => {
expect(
sanitizeUsage({
"tab.terminal": 2.7,
"Bad Name": 1,
zero: 0,
neg: -3,
nan: "x",
big: 1e9,
}),
).toEqual({ "tab.terminal": 2, big: 100_000 });
expect(sanitizeUsage(null)).toEqual({});
expect(sanitizeUsage([1])).toEqual({});
});
it("caps the number of features", () => {
const many = Object.fromEntries(
Array.from({ length: MAX_FEATURES + 20 }, (_, i) => [`f${i}`, 1]),
);
expect(Object.keys(sanitizeUsage(many))).toHaveLength(MAX_FEATURES);
});
it("maps a feature to a property name", () => {
expect(usageProperty("tab.file-manager")).toBe("used_tab_file_manager");
});
});
describe("reporting", () => {
it("sends a full report with every section on", async () => {
const { mock, usage, sent } = await setup();
await usage.add({ "tab.terminal": 3 });
mock.ctx.events.emit("host.login", { hostId: 1 });
mock.ctx.events.emit("host.session.status", { hostId: 1, online: true });
mock.ctx.events.emit("host.session.status", { hostId: 1, online: false });
await vi.waitFor(async () =>
expect(await usage.read()).toMatchObject({
ssh_login: 1,
host_connected: 1,
}),
);
await mock.runScheduled();
expect(sent).toHaveLength(1);
const { url, body } = sent[0];
expect(url).toBe("https://us.i.posthog.com/capture/");
expect(body.api_key).toBe(DEFAULT_POSTHOG_API_KEY);
expect(body.event).toBe("instance_heartbeat");
expect(typeof body.distinct_id).toBe("string");
expect(body.properties).toMatchObject({
version: "2.9.0",
user_count: 2,
host_count: 1,
os: process.platform,
arch: process.arch,
db_dialect: "sqlite",
used_tab_terminal: 3,
used_ssh_login: 1,
used_host_connected: 1,
plugins_enabled: ["docker", "telemetry"],
plugin_count: 2,
});
expect(await usage.read()).toEqual({});
});
it("checks hourly and sends at most once a day", async () => {
const { mock, sent } = await setup();
expect(mock.scheduled.find((job) => job.kind === "every")?.ms).toBe(
CHECK_INTERVAL_MS,
);
await mock.runScheduled();
await mock.runScheduled();
expect(sent).toHaveLength(1);
});
it("leaves out each section when its switch is off", async () => {
const { mock, usage, sent } = await setup({
settings: {
includePlatform: false,
includeFeatureUsage: false,
includePlugins: false,
},
});
await usage.add({ "tab.terminal": 1 });
await mock.runScheduled();
expect(Object.keys(sent[0].body.properties).sort()).toEqual([
"host_count",
"user_count",
"version",
]);
expect(await usage.read()).toEqual({ "tab.terminal": 1 });
});
it("sends nothing and counts nothing while turned off", async () => {
const { mock, usage, sent } = await setup({ settings: { enabled: false } });
mock.ctx.events.emit("host.login", { hostId: 1 });
await mock.runScheduled();
expect(sent).toEqual([]);
expect(await usage.read()).toEqual({});
});
it("lets ENABLE_TELEMETRY win over the admin switch", async () => {
const off = await setup({ env: { ENABLE_TELEMETRY: "false" } });
await off.mock.runScheduled();
expect(off.sent).toEqual([]);
const on = await setup({
settings: { enabled: false },
env: { ENABLE_TELEMETRY: "true" },
});
await on.mock.runScheduled();
expect(on.sent).toHaveLength(1);
});
it("keeps the 2.8 instance id", async () => {
const { mock, sent } = await setup({
settings: { instanceId: "legacy-id" },
});
await mock.runScheduled();
expect(sent[0].body.distinct_id).toBe("legacy-id");
});
it("records a failed send and keeps the counts", async () => {
const { mock, reporter, usage, sent } = await setup({ status: 500 });
await usage.add({ "tab.terminal": 1 });
await mock.runScheduled();
expect(sent.length).toBeGreaterThan(0);
const state = await reporter.state();
expect(state.lastError).toBe("PostHog answered 500");
expect(state.lastSentAt).toBeNull();
expect(await usage.read()).toEqual({ "tab.terminal": 1 });
});
it("refuses a change to the switch while ENABLE_TELEMETRY locks it", async () => {
const locked = await setup({ env: { ENABLE_TELEMETRY: "true" } });
expect(
await locked.mock.validateSettings("admin", { enabled: false }),
).toHaveProperty("enabled");
expect(
await locked.mock.validateSettings("admin", { enabled: true }),
).not.toHaveProperty("enabled");
const free = await setup();
expect(
await free.mock.validateSettings("admin", { enabled: false }),
).not.toHaveProperty("enabled");
});
});
describe("routes", () => {
it("serves status, preview, send and reset to a manager", async () => {
const { call, sent } = await setup();
const preview = await call("GET", "/preview");
expect(preview.status).toBe(200);
expect(preview.body.event).toBe("instance_heartbeat");
expect(preview.body).not.toHaveProperty("api_key");
expect(sent).toEqual([]);
expect((await call("POST", "/send")).body).toEqual({ sent: true });
const status = await call("GET", "/status");
expect(status.body).toMatchObject({
enabled: true,
locked: false,
lastError: null,
});
expect(status.body.lastSentAt).toBeTruthy();
expect(status.body.nextDueAt).toBeTruthy();
const before = status.body.instanceId;
const reset = await call("POST", "/reset-id");
expect(reset.body.instanceId).not.toBe(before);
});
it("answers 502 when the report cannot be delivered", async () => {
const { call } = await setup({ status: 503 });
const response = await call("POST", "/send");
expect(response.status).toBe(502);
expect(response.body.error).toBe("PostHog answered 503");
});
it("keeps the admin routes from users without manage", async () => {
const { call } = await setup({ permissions: [] });
for (const [method, path] of [
["GET", "/status"],
["GET", "/preview"],
["POST", "/send"],
["POST", "/reset-id"],
]) {
expect((await call(method, path)).status).toBe(403);
}
expect((await call("GET", "/usage/config")).status).toBe(200);
});
it("records usage only for users who are counted", async () => {
const { mock, call, usage } = await setup({ permissions: [] });
expect((await call("GET", "/usage/config")).body).toEqual({ track: true });
await call("POST", "/usage", {
features: { "tab.terminal": 2, "BAD!": 5 },
});
expect(await usage.read()).toEqual({ "tab.terminal": 2 });
await mock.ctx.settings.setUser("u2", "shareFeatureUsage", false);
expect((await call("GET", "/usage/config", undefined, "u2")).body).toEqual({
track: false,
});
const ignored = await call(
"POST",
"/usage",
{ features: { "tab.terminal": 9 } },
"u2",
);
expect(ignored.status).toBe(204);
expect(await usage.read()).toEqual({ "tab.terminal": 2 });
});
it("counts nobody while feature usage is off", async () => {
const { call, usage } = await setup({
settings: { includeFeatureUsage: false },
});
expect((await call("GET", "/usage/config")).body).toEqual({ track: false });
await call("POST", "/usage", { features: { "tab.terminal": 1 } });
expect(await usage.read()).toEqual({});
});
});
@@ -0,0 +1,193 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import {
countTabTypes,
openedSince,
startTabTracker,
} from "../../src/frontend/tracker";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const api = vi.hoisted(() => ({
get: vi.fn(),
post: vi.fn(),
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, values?: Record<string, string>) =>
values ? `${key} ${Object.values(values).join(" ")}` : key,
}),
usePluginApi: () => api,
}));
const { TelemetryStatusSetting } =
await import("../../src/frontend/TelemetryStatusSetting");
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
api.get.mockReset();
api.post.mockReset();
vi.useRealTimers();
});
function fakeApp(track: boolean) {
let layout: unknown = { version: 1, tabs: [] };
let onChange: () => void = () => undefined;
const posts: unknown[] = [];
const app = {
api: {
get: vi.fn(async () => ({ data: { track } })),
},
fetch: vi.fn(async (_path: string, init?: RequestInit) => {
posts.push(JSON.parse(String(init?.body)));
return new Response(null, { status: 204 });
}),
tabs: {
getLayout: () => layout,
onReady: (listener: () => void) => {
listener();
return () => undefined;
},
onChange: (listener: () => void) => {
onChange = listener;
return () => undefined;
},
},
onSettingsChanged: () => () => undefined,
} as unknown as TermixApp;
return {
app,
posts,
setTabs(types: string[]) {
layout = { version: 1, tabs: types.map((type) => ({ type })) };
onChange();
},
};
}
describe("tab counting", () => {
it("counts tabs per type and the ones opened since", () => {
const before = countTabTypes({
tabs: [{ type: "terminal" }, { type: "terminal" }, { type: "docker" }],
});
expect(before).toEqual({ terminal: 2, docker: 1 });
expect(countTabTypes(null)).toEqual({});
expect(
openedSince(before, { terminal: 3, docker: 0, "file-manager": 1 }),
).toEqual({ "tab.terminal": 1, "tab.file-manager": 1 });
});
});
describe("startTabTracker", () => {
it("sends newly opened tabs on the timer", async () => {
vi.useFakeTimers();
const fake = fakeApp(true);
const stop = startTabTracker(fake.app, 1000);
await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled());
await Promise.resolve();
fake.setTabs(["terminal"]);
fake.setTabs(["terminal", "terminal", "docker"]);
fake.setTabs(["docker"]);
await vi.advanceTimersByTimeAsync(1000);
expect(fake.posts).toEqual([
{ features: { "tab.terminal": 2, "tab.docker": 1 } },
]);
await vi.advanceTimersByTimeAsync(1000);
expect(fake.posts).toHaveLength(1);
stop();
});
it("sends nothing for a user who is not counted", async () => {
vi.useFakeTimers();
const fake = fakeApp(false);
const stop = startTabTracker(fake.app, 1000);
await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled());
fake.setTabs(["terminal"]);
await vi.advanceTimersByTimeAsync(1000);
stop();
expect(fake.posts).toEqual([]);
});
});
describe("telemetry activate", () => {
it("registers the status component and removes it on deactivate", async () => {
api.get.mockResolvedValue({ data: { track: false } });
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: api as unknown as PluginApiClient,
});
expect(rendered.registered.settingsComponents()).toEqual(["status"]);
await rendered.deactivate();
expect(rendered.registered.settingsComponents()).toEqual([]);
rendered = null;
});
});
describe("TelemetryStatusSetting", () => {
const props = {
pluginId: "telemetry",
values: {},
setValue: () => undefined,
running: true,
};
it("shows the state, instance id and last error", async () => {
api.get.mockResolvedValue({
data: {
enabled: true,
locked: false,
instanceId: "abc-123",
lastSentAt: null,
lastAttemptAt: "2026-09-30T00:00:00.000Z",
lastError: "PostHog answered 500",
nextDueAt: null,
},
});
render(<TelemetryStatusSetting {...props} />);
await waitFor(() => expect(screen.getByText("status.on")).toBeTruthy());
expect(screen.getByText("status.instanceId abc-123")).toBeTruthy();
expect(screen.getByText("status.neverSent")).toBeTruthy();
expect(
screen.getByText("status.lastError PostHog answered 500"),
).toBeTruthy();
});
it("says when ENABLE_TELEMETRY locks it off", async () => {
api.get.mockResolvedValue({
data: {
enabled: false,
locked: true,
instanceId: null,
lastSentAt: null,
lastAttemptAt: null,
lastError: null,
nextDueAt: null,
},
});
render(<TelemetryStatusSetting {...props} />);
await waitFor(() =>
expect(screen.getByText("status.lockedOff")).toBeTruthy(),
);
expect(
(screen.getByText("status.sendNow") as HTMLButtonElement).disabled,
).toBe(true);
});
});