mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
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:
@@ -26,4 +26,41 @@ app.get("/v1/version", async (c) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/v1/telemetry", async (c) => {
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
if (!body.instanceId || !body.version) {
|
||||||
|
return c.json({ error: "Missing required fields" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const posthogKey = process.env.POSTHOG_API_KEY;
|
||||||
|
if (!posthogKey) {
|
||||||
|
return c.body(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch("https://us.i.posthog.com/i/v0/e/", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
api_key: posthogKey,
|
||||||
|
event: "instance_report",
|
||||||
|
distinct_id: body.instanceId,
|
||||||
|
properties: {
|
||||||
|
version: body.version,
|
||||||
|
arch: body.arch,
|
||||||
|
users: body.users,
|
||||||
|
titles: body.titles,
|
||||||
|
...(body.features ?? {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Fire-and-forget — don't fail the request if PostHog is down
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
refreshTvChildren,
|
refreshTvChildren,
|
||||||
} from "@sofa/core/metadata";
|
} from "@sofa/core/metadata";
|
||||||
import { getSetting } from "@sofa/core/settings";
|
import { getSetting } from "@sofa/core/settings";
|
||||||
|
import { performTelemetryReport } from "@sofa/core/telemetry";
|
||||||
import { performUpdateCheck } from "@sofa/core/update-check";
|
import { performUpdateCheck } from "@sofa/core/update-check";
|
||||||
import { db } from "@sofa/db/client";
|
import { db } from "@sofa/db/client";
|
||||||
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
|
import { and, eq, inArray, isNotNull, lt, or } from "@sofa/db/helpers";
|
||||||
@@ -366,6 +367,9 @@ export function startJobs() {
|
|||||||
schedule("updateCheck", "0 */6 * * *", async () => {
|
schedule("updateCheck", "0 */6 * * *", async () => {
|
||||||
await performUpdateCheck();
|
await performUpdateCheck();
|
||||||
});
|
});
|
||||||
|
schedule("telemetryReport", "30 0 * * *", async () => {
|
||||||
|
await performTelemetryReport();
|
||||||
|
});
|
||||||
|
|
||||||
log.info(`Started ${jobs.size} jobs`);
|
log.info(`Started ${jobs.size} jobs`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
restoreFromBackup,
|
restoreFromBackup,
|
||||||
} from "@sofa/core/backup";
|
} from "@sofa/core/backup";
|
||||||
import { getSetting, setSetting } from "@sofa/core/settings";
|
import { getSetting, setSetting } from "@sofa/core/settings";
|
||||||
|
import { isTelemetryEnabled } from "@sofa/core/telemetry";
|
||||||
import {
|
import {
|
||||||
getCachedUpdateCheck,
|
getCachedUpdateCheck,
|
||||||
isUpdateCheckEnabled,
|
isUpdateCheckEnabled,
|
||||||
@@ -132,6 +133,21 @@ export const toggleUpdateCheck = os.admin.toggleUpdateCheck
|
|||||||
setSetting("updateCheckEnabled", String(input.enabled));
|
setSetting("updateCheckEnabled", String(input.enabled));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Telemetry ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const telemetry = os.admin.telemetry.use(admin).handler(() => {
|
||||||
|
return {
|
||||||
|
enabled: isTelemetryEnabled(),
|
||||||
|
lastReportedAt: getSetting("telemetryLastReportedAt"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toggleTelemetry = os.admin.toggleTelemetry
|
||||||
|
.use(admin)
|
||||||
|
.handler(({ input }) => {
|
||||||
|
setSetting("telemetryEnabled", String(input.enabled));
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Jobs ──────────────────────────────────────────────────────
|
// ─── Jobs ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const triggerJob = os.admin.triggerJob
|
export const triggerJob = os.admin.triggerJob
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import {
|
|||||||
isOidcConfigured,
|
isOidcConfigured,
|
||||||
isPasswordLoginDisabled,
|
isPasswordLoginDisabled,
|
||||||
} from "@sofa/auth/config";
|
} from "@sofa/auth/config";
|
||||||
import { getUserCount, isRegistrationOpen } from "@sofa/core/settings";
|
import {
|
||||||
|
getInstanceId,
|
||||||
|
getUserCount,
|
||||||
|
isRegistrationOpen,
|
||||||
|
} from "@sofa/core/settings";
|
||||||
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
import { isTmdbConfigured } from "@sofa/tmdb/config";
|
||||||
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
import { tmdbImageUrl } from "@sofa/tmdb/image";
|
||||||
import { os } from "../context";
|
import { os } from "../context";
|
||||||
@@ -30,6 +34,7 @@ export const publicInfo = os.system.publicInfo.handler(async () => {
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
instanceId: getInstanceId(),
|
||||||
tmdbConfigured: isTmdbConfigured(),
|
tmdbConfigured: isTmdbConfigured(),
|
||||||
userCount: getUserCount(),
|
userCount: getUserCount(),
|
||||||
registrationOpen: isRegistrationOpen(),
|
registrationOpen: isRegistrationOpen(),
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const router = os.router({
|
|||||||
toggleRegistration: admin.toggleRegistration,
|
toggleRegistration: admin.toggleRegistration,
|
||||||
updateCheck: admin.updateCheck,
|
updateCheck: admin.updateCheck,
|
||||||
toggleUpdateCheck: admin.toggleUpdateCheck,
|
toggleUpdateCheck: admin.toggleUpdateCheck,
|
||||||
|
telemetry: admin.telemetry,
|
||||||
|
toggleTelemetry: admin.toggleTelemetry,
|
||||||
triggerJob: admin.triggerJob,
|
triggerJob: admin.triggerJob,
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ import {
|
|||||||
SearchOutput,
|
SearchOutput,
|
||||||
SystemHealthOutput,
|
SystemHealthOutput,
|
||||||
SystemStatusOutput,
|
SystemStatusOutput,
|
||||||
|
TelemetryOutput,
|
||||||
TitleDetailOutput,
|
TitleDetailOutput,
|
||||||
TitleRecommendationsOutput,
|
TitleRecommendationsOutput,
|
||||||
TitleResolveOutput,
|
TitleResolveOutput,
|
||||||
TmdbIdParam,
|
TmdbIdParam,
|
||||||
TmdbIdTypeParam,
|
TmdbIdTypeParam,
|
||||||
ToggleRegistrationInput,
|
ToggleRegistrationInput,
|
||||||
|
ToggleTelemetryInput,
|
||||||
ToggleUpdateCheckInput,
|
ToggleUpdateCheckInput,
|
||||||
TrendingOutput,
|
TrendingOutput,
|
||||||
TrendingTypeParam,
|
TrendingTypeParam,
|
||||||
@@ -367,6 +369,13 @@ export const contract = {
|
|||||||
.route({ method: "PUT", path: "/admin/update-check", tags: ["Admin"] })
|
.route({ method: "PUT", path: "/admin/update-check", tags: ["Admin"] })
|
||||||
.input(ToggleUpdateCheckInput)
|
.input(ToggleUpdateCheckInput)
|
||||||
.output(z.void()),
|
.output(z.void()),
|
||||||
|
telemetry: oc
|
||||||
|
.route({ method: "GET", path: "/admin/telemetry", tags: ["Admin"] })
|
||||||
|
.output(TelemetryOutput),
|
||||||
|
toggleTelemetry: oc
|
||||||
|
.route({ method: "PUT", path: "/admin/telemetry", tags: ["Admin"] })
|
||||||
|
.input(ToggleTelemetryInput)
|
||||||
|
.output(z.void()),
|
||||||
triggerJob: oc
|
triggerJob: oc
|
||||||
.route({
|
.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -70,6 +70,11 @@ export const CreateIntegrationInput = z.object({
|
|||||||
|
|
||||||
export const ToggleRegistrationInput = z.object({ open: z.boolean() });
|
export const ToggleRegistrationInput = z.object({ open: z.boolean() });
|
||||||
export const ToggleUpdateCheckInput = z.object({ enabled: z.boolean() });
|
export const ToggleUpdateCheckInput = z.object({ enabled: z.boolean() });
|
||||||
|
export const ToggleTelemetryInput = z.object({ enabled: z.boolean() });
|
||||||
|
export const TelemetryOutput = z.object({
|
||||||
|
enabled: z.boolean(),
|
||||||
|
lastReportedAt: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
const cronJobName = z.enum([
|
const cronJobName = z.enum([
|
||||||
"scheduledBackup",
|
"scheduledBackup",
|
||||||
@@ -80,6 +85,7 @@ const cronJobName = z.enum([
|
|||||||
"cacheImages",
|
"cacheImages",
|
||||||
"refreshCredits",
|
"refreshCredits",
|
||||||
"updateCheck",
|
"updateCheck",
|
||||||
|
"telemetryReport",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const TriggerJobInput = z.object({ name: cronJobName });
|
export const TriggerJobInput = z.object({ name: cronJobName });
|
||||||
@@ -555,6 +561,7 @@ export const QuickAddOutput = z.object({
|
|||||||
// ─── System outputs ───────────────────────────────────────────
|
// ─── System outputs ───────────────────────────────────────────
|
||||||
|
|
||||||
export const PublicInfoOutput = z.object({
|
export const PublicInfoOutput = z.object({
|
||||||
|
instanceId: z.string(),
|
||||||
tmdbConfigured: z.boolean(),
|
tmdbConfigured: z.boolean(),
|
||||||
userCount: z.number(),
|
userCount: z.number(),
|
||||||
registrationOpen: z.boolean(),
|
registrationOpen: z.boolean(),
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"./providers": "./src/providers.ts",
|
"./providers": "./src/providers.ts",
|
||||||
"./settings": "./src/settings.ts",
|
"./settings": "./src/settings.ts",
|
||||||
"./system-health": "./src/system-health.ts",
|
"./system-health": "./src/system-health.ts",
|
||||||
|
"./telemetry": "./src/telemetry.ts",
|
||||||
"./tracking": "./src/tracking.ts",
|
"./tracking": "./src/tracking.ts",
|
||||||
"./update-check": "./src/update-check.ts",
|
"./update-check": "./src/update-check.ts",
|
||||||
"./webhooks": "./src/webhooks.ts"
|
"./webhooks": "./src/webhooks.ts"
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export function getUserCount(): number {
|
|||||||
return result?.count ?? 0;
|
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 {
|
export function isRegistrationOpen(): boolean {
|
||||||
const userCount = getUserCount();
|
const userCount = getUserCount();
|
||||||
if (userCount === 0) return true;
|
if (userCount === 0) return true;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user