mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25: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
@@ -0,0 +1,7 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
First release, bundled with Termix 2.9.0.
|
||||
|
||||
Moves the daily anonymous usage report out of Termix core. Adds platform info, feature usage counts and the list of running features, each with its own switch, a per-user opt-out, a preview of the exact report and a button to send it now.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Usage Statistics
|
||||
|
||||
Sends a small anonymous report once a day so the Termix developers can see how many instances run and which features get used.
|
||||
|
||||
## What is sent
|
||||
|
||||
Always, while it is on:
|
||||
|
||||
- A random instance ID, the Termix version, and how many users and hosts exist.
|
||||
|
||||
Each of these has its own switch in Admin Settings > Usage Statistics:
|
||||
|
||||
- Platform info: operating system, CPU architecture, Node.js version, database type, and whether Termix runs in Docker, the desktop app or as a plain server.
|
||||
- Feature usage: how many times each kind of tab was opened, plus SSH logins and host connections, since the last report.
|
||||
- Installed features: the ids of the running built-in features.
|
||||
|
||||
It never sends usernames, hostnames, IP addresses, credentials or anything else that identifies you or your servers. Admins can preview the exact report before it is sent.
|
||||
|
||||
## Turning it off
|
||||
|
||||
- Admin Settings > Usage Statistics > Share anonymous usage statistics.
|
||||
- Each user can leave their own feature usage out in User Profile > Usage Statistics.
|
||||
- `ENABLE_TELEMETRY=false` turns it off and locks the switch. `ENABLE_TELEMETRY=true` locks it on.
|
||||
|
||||
## Environment
|
||||
|
||||
- `ENABLE_TELEMETRY`: `true` or `false` overrides the admin switch.
|
||||
- `POSTHOG_API_KEY`: the PostHog project key. Defaults to the Termix project.
|
||||
- `POSTHOG_HOST`: the PostHog host. Defaults to `https://us.i.posthog.com`.
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"permissions": {
|
||||
"manage": {
|
||||
"title": "Manage usage statistics",
|
||||
"description": "See what usage statistics are sent, send them now and reset the instance ID."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"enabled": {
|
||||
"label": "Share anonymous usage statistics",
|
||||
"description": "Sends a small anonymous summary once a day to help improve Termix. It never includes usernames, hostnames, IP addresses or credentials."
|
||||
},
|
||||
"includePlatform": {
|
||||
"label": "Include platform info",
|
||||
"description": "The operating system, CPU architecture, Node.js version, database type and how Termix is installed (Docker, desktop app or server)."
|
||||
},
|
||||
"includeFeatureUsage": {
|
||||
"label": "Include feature usage",
|
||||
"description": "How many times each kind of tab was opened and how many SSH logins happened since the last report. Users can turn off their own part in their profile."
|
||||
},
|
||||
"includePlugins": {
|
||||
"label": "Include installed features",
|
||||
"description": "Which built-in features are installed and running."
|
||||
},
|
||||
"instanceId": {
|
||||
"label": "Instance ID"
|
||||
},
|
||||
"shareFeatureUsage": {
|
||||
"label": "Include my feature usage",
|
||||
"description": "Counts which kinds of tabs you open in the anonymous usage statistics. Nothing about you or your hosts is sent."
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"title": "Status",
|
||||
"on": "Sharing usage statistics once a day",
|
||||
"off": "Not sharing usage statistics",
|
||||
"lockedOn": "Turned on by the ENABLE_TELEMETRY environment variable. Change it there.",
|
||||
"lockedOff": "Turned off by the ENABLE_TELEMETRY environment variable. Change it there.",
|
||||
"instanceId": "Instance ID: {{id}}",
|
||||
"noInstanceId": "No instance ID yet",
|
||||
"lastSent": "Last sent {{date}}",
|
||||
"neverSent": "Not sent yet",
|
||||
"nextSend": "Next report due {{date}}",
|
||||
"lastError": "Last attempt failed: {{error}}",
|
||||
"preview": "Preview report",
|
||||
"sendNow": "Send now",
|
||||
"sending": "Sending...",
|
||||
"sent": "Usage statistics sent",
|
||||
"notSent": "Usage statistics are turned off, nothing was sent",
|
||||
"resetId": "Reset instance ID",
|
||||
"resetIdConfirm": "Reset the instance ID? Future reports will look like a new install.",
|
||||
"resetIdDone": "Instance ID reset",
|
||||
"previewTitle": "Next report",
|
||||
"previewDescription": "This is exactly what would be sent right now.",
|
||||
"close": "Close",
|
||||
"loadFailed": "Could not load the usage statistics status"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"id": "telemetry",
|
||||
"name": "Usage Statistics",
|
||||
"version": "1.0.0",
|
||||
"description": "Sends an anonymous daily summary of how Termix is used, to help decide what to work on next.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Monitoring",
|
||||
"icon": "ChartNoAxesColumn",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:core-refs",
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"events:core",
|
||||
"db:own",
|
||||
"kv:own",
|
||||
"plugins:read",
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"permissions": [
|
||||
{
|
||||
"name": "manage",
|
||||
"titleKey": "permissions.manage.title",
|
||||
"descriptionKey": "permissions.manage.description",
|
||||
"defaultRoles": ["admin"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"admin": [
|
||||
{
|
||||
"key": "status",
|
||||
"type": "custom",
|
||||
"component": "status",
|
||||
"permission": "manage"
|
||||
},
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.enabled.label",
|
||||
"descriptionKey": "settings.enabled.description",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "includePlatform",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.includePlatform.label",
|
||||
"descriptionKey": "settings.includePlatform.description",
|
||||
"requires": "enabled",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "includeFeatureUsage",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.includeFeatureUsage.label",
|
||||
"descriptionKey": "settings.includeFeatureUsage.description",
|
||||
"requires": "enabled",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "includePlugins",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.includePlugins.label",
|
||||
"descriptionKey": "settings.includePlugins.description",
|
||||
"requires": "enabled",
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "instanceId",
|
||||
"type": "string",
|
||||
"labelKey": "settings.instanceId.label",
|
||||
"hidden": true,
|
||||
"default": ""
|
||||
}
|
||||
],
|
||||
"user": [
|
||||
{
|
||||
"key": "shareFeatureUsage",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.shareFeatureUsage.label",
|
||||
"descriptionKey": "settings.shareFeatureUsage.description",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"desktop": "server"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@termix-plugin/telemetry",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p .",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import fs from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { envOverride } from "./config.js";
|
||||
import { usageProperty, type UsageCounts, type UsageStore } from "./usage.js";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// ctx.db hands back the server's own drizzle handle and tables, untyped.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export interface TelemetrySettings {
|
||||
enabled: boolean;
|
||||
includePlatform: boolean;
|
||||
includeFeatureUsage: boolean;
|
||||
includePlugins: boolean;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
export interface TelemetryPayload {
|
||||
event: "instance_heartbeat";
|
||||
distinct_id: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export async function readSettings(
|
||||
ctx: PluginContext,
|
||||
): Promise<TelemetrySettings> {
|
||||
const all = await ctx.settings.getAll("admin");
|
||||
return {
|
||||
enabled: all.enabled !== false,
|
||||
includePlatform: all.includePlatform !== false,
|
||||
includeFeatureUsage: all.includeFeatureUsage !== false,
|
||||
includePlugins: all.includePlugins !== false,
|
||||
instanceId: typeof all.instanceId === "string" ? all.instanceId : "",
|
||||
};
|
||||
}
|
||||
|
||||
/** The env variable decides when set, the admin switch otherwise. */
|
||||
export function isEnabled(settings: TelemetrySettings, env?: Env): boolean {
|
||||
return envOverride(env) ?? settings.enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Made on first use rather than on activate, so the boot copy of a 2.8
|
||||
* instance id is never shadowed by a new one.
|
||||
*/
|
||||
export async function ensureInstanceId(ctx: PluginContext): Promise<string> {
|
||||
const existing = await ctx.settings.get<string>("instanceId");
|
||||
if (existing) return existing;
|
||||
const id = randomUUID();
|
||||
await ctx.settings.set("instanceId", id);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function detectDeployment(
|
||||
env: Env = process.env,
|
||||
exists: (path: string) => boolean = fs.existsSync,
|
||||
): "desktop" | "docker" | "server" {
|
||||
if (env.ELECTRON_EMBEDDED === "true") return "desktop";
|
||||
if (exists("/.dockerenv")) return "docker";
|
||||
return "server";
|
||||
}
|
||||
|
||||
async function countRows(ctx: PluginContext): Promise<{
|
||||
userCount: number;
|
||||
hostCount: number;
|
||||
}> {
|
||||
const { users, hosts } = await ctx.db.refs<{ users: Table; hosts: Table }>();
|
||||
const db = await ctx.db.client<Drizzle>();
|
||||
const count = async (table: Table) => {
|
||||
const rows = await db.select({ count: sql<number>`count(*)` }).from(table);
|
||||
return Number(rows[0]?.count ?? 0);
|
||||
};
|
||||
const [userCount, hostCount] = await Promise.all([
|
||||
count(users),
|
||||
count(hosts),
|
||||
]);
|
||||
return { userCount, hostCount };
|
||||
}
|
||||
|
||||
export interface BuiltPayload {
|
||||
payload: TelemetryPayload;
|
||||
/** The counts the payload carries, to subtract once it is sent. */
|
||||
usage: UsageCounts;
|
||||
}
|
||||
|
||||
export async function buildPayload(
|
||||
ctx: PluginContext,
|
||||
usage: UsageStore,
|
||||
env: Env = process.env,
|
||||
): Promise<BuiltPayload> {
|
||||
const settings = await readSettings(ctx);
|
||||
const distinctId = await ensureInstanceId(ctx);
|
||||
const { userCount, hostCount } = await countRows(ctx);
|
||||
|
||||
const properties: Record<string, unknown> = {
|
||||
version: env.VERSION || "unknown",
|
||||
user_count: userCount,
|
||||
host_count: hostCount,
|
||||
};
|
||||
|
||||
if (settings.includePlatform) {
|
||||
properties.os = process.platform;
|
||||
properties.arch = process.arch;
|
||||
properties.node_version = process.versions.node;
|
||||
properties.db_dialect = ctx.db.dialect;
|
||||
properties.deployment = detectDeployment(env);
|
||||
}
|
||||
|
||||
let carried: UsageCounts = {};
|
||||
if (settings.includeFeatureUsage) {
|
||||
carried = await usage.read();
|
||||
for (const [name, count] of Object.entries(carried)) {
|
||||
properties[usageProperty(name)] = count;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.includePlugins) {
|
||||
const running = (await ctx.plugins.list())
|
||||
.filter((plugin) => plugin.state === "active")
|
||||
.map((plugin) => plugin.id)
|
||||
.sort();
|
||||
properties.plugins_enabled = running;
|
||||
properties.plugin_count = running.length;
|
||||
}
|
||||
|
||||
return {
|
||||
payload: {
|
||||
event: "instance_heartbeat",
|
||||
distinct_id: distinctId,
|
||||
properties,
|
||||
},
|
||||
usage: carried,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Public project key. Only lets a client send events, never read them. */
|
||||
export const DEFAULT_POSTHOG_API_KEY =
|
||||
"phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd";
|
||||
export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
/** ENABLE_TELEMETRY wins over the admin setting when set. */
|
||||
export function envOverride(env: Env = process.env): boolean | null {
|
||||
const value = env.ENABLE_TELEMETRY?.trim().toLowerCase();
|
||||
if (!value) return null;
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
export function posthogConfig(env: Env = process.env): {
|
||||
apiKey: string;
|
||||
host: string;
|
||||
} {
|
||||
return {
|
||||
apiKey: env.POSTHOG_API_KEY?.trim() || DEFAULT_POSTHOG_API_KEY,
|
||||
host: (env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { envOverride } from "./config.js";
|
||||
import { isEnabled, readSettings } from "./collect.js";
|
||||
import { CHECK_INTERVAL_MS, createReporter } from "./reporter.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { createUsageStore } from "./usage.js";
|
||||
|
||||
/** First check a few minutes after boot, once core's boot copies ran. */
|
||||
const FIRST_CHECK_DELAY_MS = 3 * 60 * 1000;
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export async function activateWith(ctx: PluginContext, env: Env) {
|
||||
const usage = createUsageStore(ctx.kv);
|
||||
const reporter = createReporter(ctx, usage, env);
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
await reporter.send();
|
||||
} catch {
|
||||
// Logged by the reporter; the next check tries again.
|
||||
}
|
||||
};
|
||||
ctx.schedule.after(FIRST_CHECK_DELAY_MS, check);
|
||||
ctx.schedule.every(CHECK_INTERVAL_MS, check);
|
||||
|
||||
const countEvent = (feature: string) => async () => {
|
||||
const settings = await readSettings(ctx);
|
||||
if (isEnabled(settings, env) && settings.includeFeatureUsage) {
|
||||
await usage.add({ [feature]: 1 });
|
||||
}
|
||||
};
|
||||
const onLogin = countEvent("ssh_login");
|
||||
const onConnected = countEvent("host_connected");
|
||||
ctx.events.on("host.login", () => void onLogin().catch(() => undefined));
|
||||
ctx.events.on("host.session.status", (payload) => {
|
||||
if ((payload as { online?: boolean } | null)?.online) {
|
||||
void onConnected().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.settings.onValidate("admin", async (values) => {
|
||||
const locked = envOverride(env);
|
||||
if (locked === null || values.enabled === undefined) return;
|
||||
const stored = (await ctx.settings.get<boolean>("enabled")) !== false;
|
||||
if (values.enabled !== stored) {
|
||||
return {
|
||||
enabled:
|
||||
"This is set by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
registerRoutes(ctx, ctx.http.router<Router>(), reporter, usage, env);
|
||||
return { reporter, usage };
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
await activateWith(ctx, process.env);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { envOverride, posthogConfig } from "./config.js";
|
||||
import { buildPayload, isEnabled, readSettings } from "./collect.js";
|
||||
import type { UsageStore } from "./usage.js";
|
||||
|
||||
export const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
/** Checked hourly, so a restart never skips or doubles a day. */
|
||||
export const CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
const STATE_KEY = "state";
|
||||
|
||||
export interface ReporterState {
|
||||
lastSentAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export type SendResult =
|
||||
{ sent: true } | { sent: false; reason: "disabled" | "not-due" };
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export function createReporter(
|
||||
ctx: PluginContext,
|
||||
usage: UsageStore,
|
||||
env: Env = process.env,
|
||||
now: () => number = Date.now,
|
||||
) {
|
||||
async function state(): Promise<ReporterState> {
|
||||
const stored = (await ctx.kv.get(STATE_KEY)) as
|
||||
Partial<ReporterState> | undefined;
|
||||
return {
|
||||
lastSentAt: stored?.lastSentAt ?? null,
|
||||
lastAttemptAt: stored?.lastAttemptAt ?? null,
|
||||
lastError: stored?.lastError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveState(patch: Partial<ReporterState>) {
|
||||
await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch });
|
||||
}
|
||||
|
||||
async function nextDueAt(): Promise<string | null> {
|
||||
const { lastSentAt } = await state();
|
||||
if (!lastSentAt) return null;
|
||||
return new Date(Date.parse(lastSentAt) + REPORT_INTERVAL_MS).toISOString();
|
||||
}
|
||||
|
||||
async function send(options: { force?: boolean } = {}): Promise<SendResult> {
|
||||
if (!isEnabled(await readSettings(ctx), env)) {
|
||||
return { sent: false, reason: "disabled" };
|
||||
}
|
||||
if (!options.force) {
|
||||
const due = await nextDueAt();
|
||||
if (due && Date.parse(due) > now()) {
|
||||
return { sent: false, reason: "not-due" };
|
||||
}
|
||||
}
|
||||
|
||||
const attemptAt = new Date(now()).toISOString();
|
||||
const { apiKey, host } = posthogConfig(env);
|
||||
try {
|
||||
const { payload, usage: carried } = await buildPayload(ctx, usage, env);
|
||||
const response = await ctx.fetch(`${host}/capture/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ api_key: apiKey, ...payload }),
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`PostHog answered ${response.status}`);
|
||||
}
|
||||
await usage.subtract(carried);
|
||||
await saveState({
|
||||
lastSentAt: attemptAt,
|
||||
lastAttemptAt: attemptAt,
|
||||
lastError: null,
|
||||
});
|
||||
ctx.log.info("Sent daily usage statistics");
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await saveState({ lastAttemptAt: attemptAt, lastError: message });
|
||||
ctx.log.warn(`Could not send usage statistics: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
send,
|
||||
|
||||
async status() {
|
||||
const settings = await readSettings(ctx);
|
||||
return {
|
||||
enabled: isEnabled(settings, env),
|
||||
locked: envOverride(env) !== null,
|
||||
instanceId: settings.instanceId || null,
|
||||
nextDueAt: await nextDueAt(),
|
||||
...(await state()),
|
||||
};
|
||||
},
|
||||
|
||||
async preview() {
|
||||
return (await buildPayload(ctx, usage, env)).payload;
|
||||
},
|
||||
|
||||
async resetInstanceId(): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await ctx.settings.set("instanceId", id);
|
||||
return id;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Reporter = ReturnType<typeof createReporter>;
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { isEnabled, readSettings } from "./collect.js";
|
||||
import type { Reporter } from "./reporter.js";
|
||||
import { sanitizeUsage, type UsageStore } from "./usage.js";
|
||||
|
||||
/** Whether the signed-in user's feature usage should be counted. */
|
||||
export async function shouldTrackUser(
|
||||
ctx: PluginContext,
|
||||
userId: string | undefined,
|
||||
env?: Record<string, string | undefined>,
|
||||
): Promise<boolean> {
|
||||
if (!userId) return false;
|
||||
const settings = await readSettings(ctx);
|
||||
if (!isEnabled(settings, env) || !settings.includeFeatureUsage) return false;
|
||||
return (await ctx.settings.getUser(userId, "shareFeatureUsage")) !== false;
|
||||
}
|
||||
|
||||
export function registerRoutes(
|
||||
ctx: PluginContext,
|
||||
router: Router,
|
||||
reporter: Reporter,
|
||||
usage: UsageStore,
|
||||
env?: Record<string, string | undefined>,
|
||||
): void {
|
||||
const manage = ctx.rbac.require("manage") as unknown as RequestHandler;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/status:
|
||||
* get:
|
||||
* summary: Usage statistics status
|
||||
* description: Whether usage statistics are sent, whether ENABLE_TELEMETRY locks that, the instance ID and the last attempt.
|
||||
* tags: [Usage Statistics]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Status.
|
||||
* 403:
|
||||
* description: Missing the telemetry.manage permission.
|
||||
*/
|
||||
router.get("/status", manage, async (_req: Request, res: Response) => {
|
||||
res.json(await reporter.status());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/preview:
|
||||
* get:
|
||||
* summary: Preview the next report
|
||||
* description: The exact event that would be sent right now, without the API key.
|
||||
* tags: [Usage Statistics]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The event.
|
||||
* 403:
|
||||
* description: Missing the telemetry.manage permission.
|
||||
*/
|
||||
router.get("/preview", manage, async (_req: Request, res: Response) => {
|
||||
res.json(await reporter.preview());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/send:
|
||||
* post:
|
||||
* summary: Send usage statistics now
|
||||
* description: Sends the report straight away. Does nothing while usage statistics are turned off.
|
||||
* tags: [Usage Statistics]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Whether a report was sent.
|
||||
* 403:
|
||||
* description: Missing the telemetry.manage permission.
|
||||
* 502:
|
||||
* description: The report could not be delivered.
|
||||
*/
|
||||
router.post("/send", manage, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await reporter.send({ force: true }));
|
||||
} catch (error) {
|
||||
res.status(502).json({
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/reset-id:
|
||||
* post:
|
||||
* summary: Reset the instance ID
|
||||
* description: Picks a new random instance ID, so later reports look like a new install.
|
||||
* tags: [Usage Statistics]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new ID.
|
||||
* 403:
|
||||
* description: Missing the telemetry.manage permission.
|
||||
*/
|
||||
router.post("/reset-id", manage, async (_req: Request, res: Response) => {
|
||||
res.json({ instanceId: await reporter.resetInstanceId() });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/usage/config:
|
||||
* get:
|
||||
* summary: Whether to count this user's feature usage
|
||||
* description: False when usage statistics or feature usage are off, or the user turned off their own part.
|
||||
* tags: [Usage Statistics]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: "{ track: boolean }"
|
||||
*/
|
||||
router.get("/usage/config", async (_req: Request, res: Response) => {
|
||||
res.json({ track: await shouldTrackUser(ctx, ctx.currentActor(), env) });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/telemetry/usage:
|
||||
* post:
|
||||
* summary: Add feature usage counts
|
||||
* description: Counts from the signed-in user's browser, added to the next report. Ignored when this user is not counted. Names and counts are checked and capped.
|
||||
* tags: [Usage Statistics]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* features:
|
||||
* type: object
|
||||
* additionalProperties:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Recorded or ignored.
|
||||
*/
|
||||
router.post("/usage", async (req: Request, res: Response) => {
|
||||
if (await shouldTrackUser(ctx, ctx.currentActor(), env)) {
|
||||
const counts = sanitizeUsage(req.body?.features);
|
||||
if (Object.keys(counts).length > 0) await usage.add(counts);
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PluginKeyValue } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const USAGE_KEY = "usage";
|
||||
const FEATURE_NAME = /^[a-z][a-z0-9_.-]{0,63}$/;
|
||||
export const MAX_FEATURES = 100;
|
||||
const MAX_COUNT = 100_000;
|
||||
|
||||
export type UsageCounts = Record<string, number>;
|
||||
|
||||
/** Keeps valid names with positive whole counts, capped. */
|
||||
export function sanitizeUsage(input: unknown): UsageCounts {
|
||||
const clean: UsageCounts = {};
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return clean;
|
||||
}
|
||||
for (const [name, raw] of Object.entries(input)) {
|
||||
if (!FEATURE_NAME.test(name)) continue;
|
||||
const count = Math.floor(Number(raw));
|
||||
if (!Number.isFinite(count) || count <= 0) continue;
|
||||
clean[name] = Math.min(count, MAX_COUNT);
|
||||
if (Object.keys(clean).length >= MAX_FEATURES) break;
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
export function createUsageStore(kv: PluginKeyValue) {
|
||||
// One write at a time, so two requests never drop each other's counts.
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
|
||||
const next = queue.then(fn, fn);
|
||||
queue = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
|
||||
const read = async (): Promise<UsageCounts> =>
|
||||
sanitizeUsage(await kv.get(USAGE_KEY));
|
||||
|
||||
return {
|
||||
read,
|
||||
|
||||
add: (counts: UsageCounts) =>
|
||||
serial(async () => {
|
||||
const current = await read();
|
||||
for (const [name, count] of Object.entries(sanitizeUsage(counts))) {
|
||||
if (
|
||||
!(name in current) &&
|
||||
Object.keys(current).length >= MAX_FEATURES
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
current[name] = Math.min((current[name] ?? 0) + count, MAX_COUNT);
|
||||
}
|
||||
await kv.set(USAGE_KEY, current);
|
||||
}),
|
||||
|
||||
/** Drops what a successful report already carried. */
|
||||
subtract: (sent: UsageCounts) =>
|
||||
serial(async () => {
|
||||
const current = await read();
|
||||
for (const [name, count] of Object.entries(sent)) {
|
||||
const left = (current[name] ?? 0) - count;
|
||||
if (left > 0) current[name] = left;
|
||||
else delete current[name];
|
||||
}
|
||||
await kv.set(USAGE_KEY, current);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export type UsageStore = ReturnType<typeof createUsageStore>;
|
||||
|
||||
/** PostHog property name for a feature: used_tab_terminal. */
|
||||
export function usageProperty(name: string): string {
|
||||
return `used_${name.replace(/[.-]/g, "_")}`;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
useConfirmation,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
|
||||
export interface TelemetryStatus {
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
instanceId: string | null;
|
||||
lastSentAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
nextDueAt: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
const data = (error as { response?: { data?: { error?: string } } })?.response
|
||||
?.data;
|
||||
if (data?.error) return data.error;
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function TelemetryStatusSetting({ running }: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [status, setStatus] = useState<TelemetryStatus | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [preview, setPreview] = useState<unknown>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get<TelemetryStatus>("/status");
|
||||
setStatus(data);
|
||||
} catch {
|
||||
setStatus(null);
|
||||
toast.error(t("status.loadFailed"));
|
||||
}
|
||||
}, [api, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (running) void load();
|
||||
}, [running, load]);
|
||||
|
||||
async function sendNow() {
|
||||
setSending(true);
|
||||
try {
|
||||
const { data } = await api.post<{ sent: boolean }>("/send");
|
||||
if (data.sent) toast.success(t("status.sent"));
|
||||
else toast.info(t("status.notSent"));
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
} finally {
|
||||
setSending(false);
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
async function showPreview() {
|
||||
try {
|
||||
const { data } = await api.get("/preview");
|
||||
setPreview(data);
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function resetId() {
|
||||
void confirmWithToast(t("status.resetIdConfirm"), async () => {
|
||||
try {
|
||||
await api.post("/reset-id");
|
||||
toast.success(t("status.resetIdDone"));
|
||||
await load();
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let summary = t("status.off");
|
||||
if (status?.locked) {
|
||||
summary = status.enabled ? t("status.lockedOn") : t("status.lockedOff");
|
||||
} else if (status?.enabled) {
|
||||
summary = t("status.on");
|
||||
}
|
||||
|
||||
const muted = "text-[10px] text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border border-border bg-background/50 p-2">
|
||||
<span className="text-xs font-medium">{t("status.title")}</span>
|
||||
{status && (
|
||||
<>
|
||||
<span className={muted}>{summary}</span>
|
||||
<span className={`${muted} break-all`}>
|
||||
{status.instanceId
|
||||
? t("status.instanceId", { id: status.instanceId })
|
||||
: t("status.noInstanceId")}
|
||||
</span>
|
||||
<span className={muted}>
|
||||
{status.lastSentAt
|
||||
? t("status.lastSent", {
|
||||
date: new Date(status.lastSentAt).toLocaleString(),
|
||||
})
|
||||
: t("status.neverSent")}
|
||||
</span>
|
||||
{status.enabled && status.nextDueAt && (
|
||||
<span className={muted}>
|
||||
{t("status.nextSend", {
|
||||
date: new Date(status.nextDueAt).toLocaleString(),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{status.lastError && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
{t("status.lastError", { error: status.lastError })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
disabled={!running}
|
||||
onClick={showPreview}
|
||||
>
|
||||
{t("status.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
disabled={!running || sending || !status?.enabled}
|
||||
onClick={sendNow}
|
||||
>
|
||||
{sending ? t("status.sending") : t("status.sendNow")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
disabled={!running}
|
||||
onClick={resetId}
|
||||
>
|
||||
{t("status.resetId")}
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog
|
||||
open={preview !== null}
|
||||
onOpenChange={(open) => !open && setPreview(null)}
|
||||
>
|
||||
<DialogContent className="rounded-none sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("status.previewTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("status.previewDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<pre className="max-h-[60vh] overflow-auto border border-border bg-muted/40 p-2 text-[11px]">
|
||||
{JSON.stringify(preview, null, 2)}
|
||||
</pre>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
{t("status.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { TelemetryStatusSetting } from "./TelemetryStatusSetting";
|
||||
import { startTabTracker } from "./tracker";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerSettingsComponent("status", TelemetryStatusSetting);
|
||||
if (!app.guest) app.onDispose(startTabTracker(app));
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
// Registrations through app are disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export const FLUSH_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
type Counts = Record<string, number>;
|
||||
|
||||
/** Open tabs per type. Slot ids change on every read, so only types count. */
|
||||
export function countTabTypes(layout: unknown): Counts {
|
||||
const tabs = (layout as { tabs?: unknown } | null)?.tabs;
|
||||
const counts: Counts = {};
|
||||
if (!Array.isArray(tabs)) return counts;
|
||||
for (const tab of tabs) {
|
||||
const type = (tab as { type?: unknown })?.type;
|
||||
if (typeof type === "string" && type)
|
||||
counts[type] = (counts[type] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Tabs opened between two snapshots, keyed as tab.<type>. */
|
||||
export function openedSince(before: Counts, after: Counts): Counts {
|
||||
const opened: Counts = {};
|
||||
for (const [type, count] of Object.entries(after)) {
|
||||
const added = count - (before[type] ?? 0);
|
||||
if (added > 0) opened[`tab.${type}`] = added;
|
||||
}
|
||||
return opened;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts which kinds of tabs the user opens and sends the totals to the
|
||||
* plugin's backend every few minutes. Does nothing unless the backend says
|
||||
* this user is counted.
|
||||
*/
|
||||
export function startTabTracker(
|
||||
app: TermixApp,
|
||||
flushMs: number = FLUSH_INTERVAL_MS,
|
||||
): () => void {
|
||||
let track = false;
|
||||
let ready = false;
|
||||
let previous: Counts = {};
|
||||
let pending: Counts = {};
|
||||
|
||||
const snapshot = () => countTabTypes(app.tabs.getLayout());
|
||||
|
||||
const refreshConfig = async () => {
|
||||
try {
|
||||
const { data } = await app.api.get<{ track?: boolean }>("/usage/config");
|
||||
track = data?.track === true;
|
||||
} catch {
|
||||
track = false;
|
||||
}
|
||||
if (!track) pending = {};
|
||||
};
|
||||
|
||||
const flush = (keepalive = false) => {
|
||||
if (!track || Object.keys(pending).length === 0) return;
|
||||
const features = pending;
|
||||
pending = {};
|
||||
void app
|
||||
.fetch("/usage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ features }),
|
||||
keepalive,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
|
||||
disposers.push(
|
||||
app.tabs.onReady(() => {
|
||||
// Tabs restored after login were opened in an earlier session.
|
||||
previous = snapshot();
|
||||
ready = true;
|
||||
}),
|
||||
);
|
||||
|
||||
disposers.push(
|
||||
app.tabs.onChange(() => {
|
||||
const current = snapshot();
|
||||
if (ready && track) {
|
||||
for (const [name, count] of Object.entries(
|
||||
openedSince(previous, current),
|
||||
)) {
|
||||
pending[name] = (pending[name] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
previous = current;
|
||||
}),
|
||||
);
|
||||
|
||||
disposers.push(app.onSettingsChanged(() => void refreshConfig()));
|
||||
|
||||
const timer = setInterval(() => flush(), flushMs);
|
||||
disposers.push(() => clearInterval(timer));
|
||||
|
||||
const onPageHide = () => flush(true);
|
||||
window.addEventListener("pagehide", onPageHide);
|
||||
disposers.push(() => window.removeEventListener("pagehide", onPageHide));
|
||||
|
||||
void refreshConfig();
|
||||
|
||||
return () => {
|
||||
flush(true);
|
||||
for (const dispose of disposers) dispose();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user