feat: add opt-in anonymous telemetry reporting

Add an opt-in telemetry system that sends anonymised instance
statistics to the public API once per day, which proxies them to
PostHog when `POSTHOG_API_KEY` is configured.

- Add `packages/core/src/telemetry.ts` — `performTelemetryReport`
  and `isTelemetryEnabled`; user and title counts are bucketed
  before sending to avoid exposing exact figures
- Add `getInstanceId()` to `settings.ts` — generates and persists a
  stable UUIDv7 for the instance
- Schedule a daily `telemetryReport` cron job (00:30)
- Add `POST /v1/telemetry` to `apps/public-api`; forwards payload
  to PostHog or returns 204 silently if key is absent
- Add `admin.telemetry` and `admin.toggleTelemetry` oRPC procedures
  for inspecting and toggling the setting
- Expose `instanceId` on `system.publicInfo`
This commit is contained in:
2026-03-13 21:14:56 -04:00
parent b7bacb352f
commit 9278ea6e73
10 changed files with 185 additions and 1 deletions
+8
View File
@@ -23,6 +23,14 @@ export function getUserCount(): number {
return result?.count ?? 0;
}
export function getInstanceId(): string {
const existing = getSetting("instanceId");
if (existing) return existing;
const id = Bun.randomUUIDv7();
setSetting("instanceId", id);
return id;
}
export function isRegistrationOpen(): boolean {
const userCount = getUserCount();
if (userCount === 0) return true;
+95
View File
@@ -0,0 +1,95 @@
import { db } from "@sofa/db/client";
import { count } from "@sofa/db/helpers";
import { titles } from "@sofa/db/schema";
import { createLogger } from "@sofa/logger";
import { imageCacheEnabled } from "./image-cache";
import {
getInstanceId,
getSetting,
getUserCount,
setSetting,
} from "./settings";
const APP_VERSION = process.env.APP_VERSION || "0.0.0";
const PUBLIC_API_URL =
process.env.PUBLIC_API_URL || "https://public-api.sofa.watch";
const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
const log = createLogger("telemetry");
export function isTelemetryEnabled(): boolean {
return getSetting("telemetryEnabled") === "true";
}
function bucketUsers(n: number): string {
if (n <= 1) return "1";
if (n <= 5) return "2-5";
if (n <= 10) return "6-10";
if (n <= 25) return "11-25";
return "26+";
}
function bucketTitles(n: number): string {
if (n === 0) return "0";
if (n <= 50) return "1-50";
if (n <= 200) return "51-200";
if (n <= 500) return "201-500";
return "501+";
}
function getTitleCount(): number {
const result = db.select({ count: count() }).from(titles).get();
return result?.count ?? 0;
}
export async function performTelemetryReport(): Promise<void> {
if (!isTelemetryEnabled()) {
log.debug("Telemetry disabled, skipping");
return;
}
// Respect report interval
const lastReported = getSetting("telemetryLastReportedAt");
if (lastReported) {
const elapsed = Date.now() - new Date(lastReported).getTime();
if (elapsed < REPORT_INTERVAL_MS) {
return;
}
}
try {
const body = {
instanceId: getInstanceId(),
version: APP_VERSION,
arch: `${process.platform}-${process.arch}`,
users: bucketUsers(getUserCount()),
titles: bucketTitles(getTitleCount()),
features: {
imageCache: imageCacheEnabled(),
oidc: !!(
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_ISSUER_URL
),
scheduledBackups: getSetting("scheduledBackups") === "true",
},
};
const res = await fetch(`${PUBLIC_API_URL}/v1/telemetry`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "sofa-telemetry",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) throw new Error(`Public API ${res.status}`);
setSetting("telemetryLastReportedAt", new Date().toISOString());
log.info("Telemetry report sent");
} catch (err) {
log.warn("Telemetry report failed:", err);
}
}