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

@@ -11,7 +11,6 @@ import {
AUDIT_FORWARD_URL_ENV,
AUDIT_FORWARD_URL_SETTING,
} from "../../utils/audit-forwarder.js";
import { getTelemetryEnvOverride } from "../../utils/analytics.js";
import {
NOTIFICATION_PRIVATE_ALLOWLIST_KEY,
parseNotificationAllowlist,
@@ -193,117 +192,6 @@ export function registerUserSettingsRoutes(
}
});
/**
* @openapi
* /users/analytics-enabled:
* get:
* summary: Get analytics enabled setting
* description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable.
* tags:
* - Users
* responses:
* 200:
* description: Analytics enabled status.
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* locked:
* type: boolean
*/
router.get("/analytics-enabled", authenticateJWT, async (_req, res) => {
try {
const override = getTelemetryEnvOverride();
if (override !== null) {
return res.json({ enabled: override, locked: true });
}
res.json({
enabled: await createCurrentSettingsRepository().getBoolean(
"analytics_enabled",
true,
),
locked: false,
});
} catch (err) {
authLogger.error("Failed to get analytics enabled setting", err);
res
.status(500)
.json({ error: "Failed to get analytics enabled setting" });
}
});
/**
* @openapi
* /users/analytics-enabled:
* patch:
* summary: Update analytics enabled setting (admin only)
* description: Enables or disables the daily anonymous usage telemetry heartbeat.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* responses:
* 200:
* description: Setting updated.
* 403:
* description: Not authorized.
* 409:
* description: Setting is locked by the ENABLE_TELEMETRY environment variable.
* 500:
* description: Failed to update setting.
*/
router.patch("/analytics-enabled", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
if (getTelemetryEnvOverride() !== null) {
return res.status(409).json({
error: "Telemetry is locked by the ENABLE_TELEMETRY env variable",
});
}
const { enabled } = req.body;
if (typeof enabled !== "boolean") {
return res.status(400).json({ error: "enabled must be a boolean" });
}
await createCurrentSettingsRepository().set(
"analytics_enabled",
enabled ? "true" : "false",
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_analytics_enabled",
resourceType: "setting",
details: JSON.stringify({ enabled }),
ipAddress,
userAgent,
success: true,
});
res.json({ enabled });
} catch (err) {
authLogger.error("Failed to update analytics enabled setting", err);
res
.status(500)
.json({ error: "Failed to update analytics enabled setting" });
}
});
/**
* @openapi
* /users/audit-forwarding:
+29
View File
@@ -0,0 +1,29 @@
/**
* ctx.plugins: which plugins are installed and whether each runs, under
* plugins:read. Ids and versions only, nothing operational.
*/
import type { PluginPlugins } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
export function createPluginPlugins(manifest: PluginManifest): PluginPlugins {
return {
list: async () => {
await assertCapability(
manifest.id,
"plugins:read",
manifest.capabilities,
);
const { getPluginRuntime } = await import("./index.js");
return getPluginRuntime()
.loader.list()
.map((plugin) => ({
id: plugin.id,
version: plugin.manifest.version,
source: plugin.source,
state: plugin.state,
}));
},
};
}
+2
View File
@@ -61,6 +61,7 @@ import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginPlugins } from "./ctx-plugins.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -945,6 +946,7 @@ export function createPluginContext(
}),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
plugins: createPluginPlugins(manifest),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
-3
View File
@@ -274,9 +274,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
});
}
const { startAnalyticsHeartbeat } = await import("./utils/analytics.js");
startAnalyticsHeartbeat();
// After plugins, so their sync entities are registered before a pass.
if (process.env.ELECTRON_EMBEDDED === "true") {
try {
@@ -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,
}),
});
});
});
+2
View File
@@ -20,6 +20,7 @@ import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.j
import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js";
import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js";
import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js";
import { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js";
import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js";
import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js";
import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js";
@@ -67,6 +68,7 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
["runNotificationChannelMigration", runNotificationChannelMigration],
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
["runSsoSettingsMigration", runSsoSettingsMigration],
["runTelemetrySettingsMigration", runTelemetrySettingsMigration],
// Last: it reads what the moves above wrote.
["runHostDefaultsMigration", runHostDefaultsMigration],
];
@@ -0,0 +1,74 @@
/**
* Moves the 2.8 telemetry settings into the telemetry plugin: the admin
* switch (settings.analytics_enabled) into its `enabled` admin setting, and
* the instance id (settings.analytics_instance_id) into `instanceId`, so the
* instance keeps reporting under the same id.
*
* Idempotent: nothing is written where the plugin already has a row. The
* legacy settings rows stay in place until 3.0.0. The plugin waits a few
* minutes before its first send, so this lands first.
*/
import { databaseLogger } from "../utils/logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../database/repositories/factory.js";
const PLUGIN_ID = "telemetry";
export interface TelemetrySettingsMigrationResult {
movedEnabled: boolean;
movedInstanceId: boolean;
}
export async function runTelemetrySettingsMigration(): Promise<TelemetrySettingsMigrationResult> {
const result: TelemetrySettingsMigrationResult = {
movedEnabled: false,
movedInstanceId: false,
};
// plugin_settings has a foreign key to plugins.
try {
if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) {
return result;
}
} catch {
return result;
}
const coreSettings = createCurrentSettingsRepository();
const pluginSettings = createCurrentPluginSettingsRepository();
const moves: Array<[string, string, unknown]> = [];
if ((await coreSettings.get("analytics_enabled")) === "false") {
moves.push(["analytics_enabled", "enabled", false]);
}
const instanceId = await coreSettings.get("analytics_instance_id");
if (instanceId) {
moves.push(["analytics_instance_id", "instanceId", instanceId]);
}
for (const [, key, value] of moves) {
const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, key);
if (existing && existing.value !== null) continue;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
key,
JSON.stringify(value),
);
if (key === "enabled") result.movedEnabled = true;
else result.movedInstanceId = true;
}
if (result.movedEnabled || result.movedInstanceId) {
databaseLogger.info("Moved telemetry settings into plugin settings", {
operation: "telemetry_settings_migration",
...result,
});
}
return result;
}
-145
View File
@@ -1,145 +0,0 @@
import { getErrorMessage } from "./error-message.js";
import crypto from "crypto";
import axios from "axios";
import { sql } from "drizzle-orm";
import { users, hosts, recentActivity } from "../database/db/schema.js";
import {
createCurrentSettingsRepository,
createCurrentRepositoryContext,
} from "../database/repositories/factory.js";
import { Logger } from "./logger.js";
const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4");
const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com";
const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000;
export function getTelemetryEnvOverride(): boolean | null {
const envVal = process.env.ENABLE_TELEMETRY;
if (envVal === undefined) return null;
const normalized = envVal.trim().toLowerCase();
if (normalized === "") return null;
return normalized === "true";
}
export async function isAnalyticsEnabled(): Promise<boolean> {
const override = getTelemetryEnvOverride();
if (override !== null) return override;
return createCurrentSettingsRepository().getBoolean(
"analytics_enabled",
true,
);
}
export async function getOrCreateInstanceId(): Promise<string> {
const settings = createCurrentSettingsRepository();
const existing = await settings.get("analytics_instance_id");
if (existing) return existing;
const id = crypto.randomUUID();
await settings.set("analytics_instance_id", id);
return id;
}
function getAppVersion(): string {
return process.env.VERSION || "unknown";
}
async function collectFeatureUsage(): Promise<Record<string, number>> {
const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString();
const db = createCurrentRepositoryContext().drizzle;
const rows = await db
.select({
type: recentActivity.type,
count: sql<number>`count(*)`,
})
.from(recentActivity)
.where(sql`${recentActivity.timestamp} >= ${since}`)
.groupBy(recentActivity.type);
// Activity types come from whichever plugins recorded them, so report the
// ones that appeared rather than a fixed list.
const usage: Record<string, number> = {};
for (const row of rows) {
if (/^[a-z][a-z0-9_]*$/.test(row.type)) {
usage[`used_${row.type}`] = Number(row.count);
}
}
return usage;
}
async function collectCounts(): Promise<{
userCount: number;
hostCount: number;
}> {
const db = createCurrentRepositoryContext().drizzle;
const [userRows, hostRows] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(users),
db.select({ count: sql<number>`count(*)` }).from(hosts),
]);
return {
userCount: Number(userRows[0]?.count ?? 0),
hostCount: Number(hostRows[0]?.count ?? 0),
};
}
export async function collectAndSendHeartbeat(): Promise<void> {
const apiKey = process.env.POSTHOG_API_KEY;
if (!apiKey) return;
try {
if (!(await isAnalyticsEnabled())) return;
const instanceId = await getOrCreateInstanceId();
const { userCount, hostCount } = await collectCounts();
const featureUsage = await collectFeatureUsage();
await axios.post(
`${POSTHOG_HOST}/capture/`,
{
api_key: apiKey,
event: "instance_heartbeat",
distinct_id: instanceId,
properties: {
version: getAppVersion(),
user_count: userCount,
host_count: hostCount,
...featureUsage,
},
},
{ timeout: 10000 },
);
analyticsLogger.info("Sent daily usage heartbeat", {
operation: "analytics_heartbeat_sent",
});
} catch (err) {
analyticsLogger.warn("Failed to send usage heartbeat", {
operation: "analytics_heartbeat_failed",
error: getErrorMessage(err),
});
}
}
export function startAnalyticsHeartbeat(): void {
if (getTelemetryEnvOverride() === false) {
analyticsLogger.info("Telemetry disabled by ENABLE_TELEMETRY", {
operation: "analytics_disabled_by_env",
});
return;
}
if (!process.env.POSTHOG_API_KEY) {
analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", {
operation: "analytics_disabled_no_key",
});
return;
}
void collectAndSendHeartbeat();
setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS);
}
-29
View File
@@ -46,35 +46,6 @@ export async function updateSessionTimeout(
// Tailscale settings/device API wrappers moved to
// plugins/tailscale/src/frontend/tailscale-api.ts.
// ============================================================================
// ANALYTICS SETTINGS
// ============================================================================
export async function getAnalyticsEnabled(): Promise<{
enabled: boolean;
locked?: boolean;
}> {
try {
const response = await authApi.get("/users/analytics-enabled");
return response.data;
} catch (error) {
handleApiError(error, "fetch analytics enabled setting");
}
}
export async function updateAnalyticsEnabled(
enabled: boolean,
): Promise<{ enabled: boolean }> {
try {
const response = await authApi.patch("/users/analytics-enabled", {
enabled,
});
return response.data;
} catch (error) {
handleApiError(error, "update analytics enabled setting");
}
}
// ============================================================================
// BRANDING SETTINGS
// ============================================================================
+4 -4
View File
@@ -1255,10 +1255,6 @@
"secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.",
"allowPasswordReset": "Allow Password Reset",
"allowPasswordResetDesc": "Reset code via Docker logs",
"analyticsEnabled": "Share Anonymous Usage Statistics",
"analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.",
"analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.",
"updateAnalyticsFailed": "Failed to update analytics setting",
"auditForwardingTitle": "Forward to SIEM",
"auditForwardingDesc": "Ship a copy of every audit entry to an external collector as NDJSON. Local logs stay the source of truth.",
"auditForwardingEnvNotice": "An environment variable also configures forwarding; the URL saved here takes precedence.",
@@ -1872,6 +1868,10 @@
"title": "Read Termix settings",
"consequence": "Server settings, not your hosts or credentials."
},
"plugins:read": {
"title": "See installed features",
"consequence": "It can see which features are installed and running."
},
"ui:surface": {
"title": "Add its own screens",
"consequence": "Tabs, panels and settings you can hide later."
-25
View File
@@ -37,8 +37,6 @@ import {
getUserRoles,
} from "@/main-axios";
import {
getAnalyticsEnabled,
updateAnalyticsEnabled,
getBranding,
updateBranding,
type BrandingSettings,
@@ -121,8 +119,6 @@ export function AdminSettingsPanel({
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("60");
const [logLevel, setLogLevel] = useState("info");
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
@@ -286,7 +282,6 @@ export function AdminSettingsPanel({
level,
oidcProv,
secondFactorExternal,
analytics,
notificationEndpoints,
branding,
] = await Promise.allSettled([
@@ -298,7 +293,6 @@ export function AdminSettingsPanel({
getLogLevel(),
getExternalAutoProvision(),
getSecondFactorAfterExternalLogin(),
getAnalyticsEnabled(),
getNotificationPrivateEndpoints(),
getBranding(),
]);
@@ -323,10 +317,6 @@ export function AdminSettingsPanel({
}
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (analytics.status === "fulfilled") {
setAnalyticsEnabled(analytics.value.enabled);
setAnalyticsLocked(analytics.value.locked ?? false);
}
if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value);
}
@@ -407,18 +397,6 @@ export function AdminSettingsPanel({
}
}
async function handleToggleAnalytics() {
if (analyticsLocked) return;
const newVal = !analyticsEnabled;
setAnalyticsEnabled(newVal);
try {
await updateAnalyticsEnabled(newVal);
} catch {
setAnalyticsEnabled(!newVal);
toast.error(t("admin.updateAnalyticsFailed"));
}
}
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
const previous = notificationPrivateEndpoints;
setNotificationPrivateEndpoints(hosts);
@@ -784,9 +762,6 @@ export function AdminSettingsPanel({
<AdminGeneralSettingsSection
open={openSections.has("general")}
onToggle={() => toggle("general")}
analyticsEnabled={analyticsEnabled}
analyticsLocked={analyticsLocked}
handleToggleAnalytics={handleToggleAnalytics}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
handleSaveNotificationPrivateEndpoints
-20
View File
@@ -10,9 +10,6 @@ import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
open: boolean;
onToggle: () => void;
analyticsEnabled: boolean;
analyticsLocked: boolean;
handleToggleAnalytics: () => void;
notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
allowRegistration: boolean;
@@ -39,9 +36,6 @@ type GeneralSettingsSectionProps = {
export function AdminGeneralSettingsSection({
open,
onToggle,
analyticsEnabled,
analyticsLocked,
handleToggleAnalytics,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
allowRegistration,
@@ -74,20 +68,6 @@ export function AdminGeneralSettingsSection({
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<SettingRow
label={t("admin.analyticsEnabled")}
description={
analyticsLocked
? t("admin.analyticsEnabledLockedDesc")
: t("admin.analyticsEnabledDesc")
}
>
<AdminToggle
on={analyticsEnabled}
onToggle={handleToggleAnalytics}
disabled={analyticsLocked}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}