feat(analytics): migrate from Umami to PostHog server-side analytics (#5983)
* feat(analytics): migrate from Umami to PostHog server-side analytics Replace @umami/node with posthog-node for all server-side telemetry. Add singleton PostHog client, feature tracking POC, DAU heartbeat, and tRPC analytics router. Cron job is now locked (no manual trigger, no custom interval). Update consent copy to explain what we collect and why. * style: auto-fix lint and format * refactor(analytics): simplify + add client-side posthog-js init - Inline constants.ts and track.ts into client.ts (single source) - Remove dead exports (shutdownPostHogAsync, getPostHogClient) - Flatten all DB queries into one Promise.all (no waterfall) - Derive integration/widget totals from group-by (drop redundant counts) - Add posthog-js with dynamic import, gated on enableGeneral - Move Analytics component to body, pass consent from server settings * style: auto-fix lint and format * feat(cron): add hidden flag to cron jobs, hide analytics from task panel The analytics job runs silently in the background — users don't need to see or interact with it. Hidden jobs are filtered out of getAllAsync so they never appear in the management UI. * refactor(analytics): simplify to single toggle, always collect all data Remove granular enableWidgetData/enableIntegrationData/enableUserData toggles. Analytics is now a single on/off switch. When enabled, all anonymous usage data is collected in one flat Promise.all call. Side effect: existing users with enableGeneral=true who had granular flags off will now send all data. This is by design — the granular controls added complexity without meaningful privacy benefit since the data is already anonymous counts. * style: auto-fix lint and format
This commit is contained in:
1 parent
d9cbd5427d
commit
d059023b51
23 files changed
+625
-929
No files matched your search
@@ -89,6 +89,7 @@
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"prismjs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -139,11 +139,11 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<Analytics />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -17,9 +17,10 @@ interface AnalyticsSettingsProps {
|
||||
|
||||
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
|
||||
const t = useScopedI18n("management.page.settings.section.analytics");
|
||||
const { instanceId: _, ...formDefaults } = initialData;
|
||||
const form = useForm({
|
||||
initialValues: initialData,
|
||||
onValuesChange: (updatedValues, _) => {
|
||||
initialValues: formDefaults,
|
||||
onValuesChange: (updatedValues, _prev) => {
|
||||
if (!form.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import Script from "next/script";
|
||||
"use client";
|
||||
|
||||
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Analytics = async () => {
|
||||
// For static pages it will not find any analytics data so we do not include the script on them
|
||||
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
|
||||
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
|
||||
if (analytics?.enableGeneral) {
|
||||
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||
}
|
||||
export const Analytics = ({ enabled }: { enabled: boolean }) => {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
return <></>;
|
||||
void import("posthog-js").then(({ default: posthog }) => {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
autocapture: false,
|
||||
persistence: "memory",
|
||||
});
|
||||
});
|
||||
}, [enabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.6.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0",
|
||||
"packageManager": "pnpm@11.7.0",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
"@homarr/db": "workspace:^0.1.0",
|
||||
"@homarr/docker": "workspace:^0.1.0",
|
||||
"@homarr/server-settings": "workspace:^0.1.0",
|
||||
"@umami/node": "catalog:",
|
||||
"superjson": "catalog:"
|
||||
"posthog-node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
export const POSTHOG_API_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
|
||||
export const POSTHOG_HOST = "https://hog.homarr.dev";
|
||||
|
||||
let instance: PostHog | undefined;
|
||||
|
||||
export const getPostHogClient = (): PostHog => {
|
||||
instance ??= new PostHog(POSTHOG_API_KEY, {
|
||||
host: POSTHOG_HOST,
|
||||
flushAt: 10,
|
||||
flushInterval: 30_000,
|
||||
});
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const trackEvent = (instanceId: string, event: string, properties?: Record<string, unknown>) => {
|
||||
getPostHogClient().capture({
|
||||
distinctId: instanceId,
|
||||
event,
|
||||
properties,
|
||||
});
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
||||
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./constants";
|
||||
export * from "./send-server-analytics";
|
||||
export { trackEvent } from "./client";
|
||||
export { sendServerAnalyticsAsync } from "./send-server-analytics";
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { UmamiEventData } from "@umami/node";
|
||||
import { Umami } from "@umami/node";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { Stopwatch } from "@homarr/common";
|
||||
import { createId, Stopwatch } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { count, db } from "@homarr/db";
|
||||
import { isMysql, isPostgresql } from "@homarr/db/collection";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
import {
|
||||
accounts,
|
||||
apiKeys,
|
||||
apps,
|
||||
boards,
|
||||
cronJobConfigurations,
|
||||
customWidgetDefinitions,
|
||||
groups,
|
||||
iconRepositories,
|
||||
icons,
|
||||
@@ -24,86 +23,160 @@ import {
|
||||
searchEngines,
|
||||
sectionLayouts,
|
||||
sections,
|
||||
sessions,
|
||||
trustedCertificateHostnames,
|
||||
users,
|
||||
} from "@homarr/db/schema";
|
||||
import { env as dockerEnv } from "@homarr/docker/env";
|
||||
|
||||
import packageJson from "../../../package.json";
|
||||
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||
import { getPostHogClient } from "./client";
|
||||
|
||||
const logger = createLogger({ module: "analytics" });
|
||||
|
||||
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
|
||||
if (isMysql()) return "mysql";
|
||||
if (isPostgresql()) return "postgresql";
|
||||
return "sqlite";
|
||||
type AnalyticsResult = "sent" | "disabled" | "failed";
|
||||
|
||||
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
|
||||
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
|
||||
const getOrCreateInstanceId = async (
|
||||
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
|
||||
): Promise<string> => {
|
||||
if (analyticsSettings.instanceId) return analyticsSettings.instanceId;
|
||||
|
||||
const instanceId = createId();
|
||||
await updateServerSettingByKeyAsync(db, "analytics", { ...analyticsSettings, instanceId });
|
||||
|
||||
const verified = await getServerSettingByKeyAsync(db, "analytics");
|
||||
return verified.instanceId ?? instanceId;
|
||||
};
|
||||
|
||||
const getEnabledAuthProviders = (): string[] => {
|
||||
const providers: string[] = [];
|
||||
if (isProviderEnabled("credentials")) providers.push("credentials");
|
||||
if (isProviderEnabled("oidc")) providers.push("oidc");
|
||||
if (isProviderEnabled("ldap")) providers.push("ldap");
|
||||
return providers;
|
||||
};
|
||||
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
|
||||
|
||||
export const sendServerAnalyticsAsync = async () => {
|
||||
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
|
||||
const stopWatch = new Stopwatch();
|
||||
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticsSettings.enableGeneral) {
|
||||
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||
return;
|
||||
return "disabled";
|
||||
}
|
||||
|
||||
const umamiInstance = new Umami();
|
||||
umamiInstance.init({
|
||||
hostUrl: UMAMI_HOST_URL,
|
||||
websiteId: UMAMI_WEBSITE_ID,
|
||||
});
|
||||
const client = getPostHogClient();
|
||||
|
||||
const enabledAuthProviders = getEnabledAuthProviders();
|
||||
try {
|
||||
const instanceId = await getOrCreateInstanceId(analyticsSettings);
|
||||
|
||||
const analyticsData: UmamiEventData = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: getDatabaseType(),
|
||||
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
|
||||
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
|
||||
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
|
||||
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
|
||||
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
|
||||
countUsers: await db.$count(users),
|
||||
countBoards: await db.$count(boards),
|
||||
countGroups: await db.$count(groups),
|
||||
countApps: await db.$count(apps),
|
||||
countWidgets: await db.$count(items),
|
||||
countSections: await db.$count(sections),
|
||||
countIntegrations: await db.$count(integrations),
|
||||
countSearchEngines: await db.$count(searchEngines),
|
||||
countIconRepositories: await db.$count(iconRepositories),
|
||||
countIcons: await db.$count(icons),
|
||||
countApiKeys: await db.$count(apiKeys),
|
||||
countInvites: await db.$count(invites),
|
||||
countMedias: await db.$count(medias),
|
||||
countLayouts: await db.$count(layouts),
|
||||
countItemLayouts: await db.$count(itemLayouts),
|
||||
countSectionLayouts: await db.$count(sectionLayouts),
|
||||
countCronJobConfigs: await db.$count(cronJobConfigurations),
|
||||
};
|
||||
const [
|
||||
cultureSettings,
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
integrationKinds,
|
||||
widgetKinds,
|
||||
] = await Promise.all([
|
||||
getServerSettingByKeyAsync(db, "culture"),
|
||||
db.$count(boards),
|
||||
db.$count(groups),
|
||||
db.$count(apps),
|
||||
db.$count(sections),
|
||||
db.$count(searchEngines),
|
||||
db.$count(iconRepositories),
|
||||
db.$count(icons),
|
||||
db.$count(layouts),
|
||||
db.$count(itemLayouts),
|
||||
db.$count(sectionLayouts),
|
||||
db.$count(cronJobConfigurations),
|
||||
db.$count(customWidgetDefinitions),
|
||||
db.$count(trustedCertificateHostnames),
|
||||
db.$count(users),
|
||||
db.$count(apiKeys),
|
||||
db.$count(invites),
|
||||
db.$count(medias),
|
||||
db.$count(sessions),
|
||||
db.$count(accounts),
|
||||
db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind),
|
||||
db
|
||||
.select({ kind: items.kind, count: count(items.id) })
|
||||
.from(items)
|
||||
.groupBy(items.kind),
|
||||
]);
|
||||
|
||||
const integrationKinds = await db
|
||||
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||
.from(integrations)
|
||||
.groupBy(integrations.kind);
|
||||
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
|
||||
|
||||
integrationKinds.forEach((integrationKind) => {
|
||||
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
|
||||
});
|
||||
const properties: Record<string, unknown> = {
|
||||
homarrVersion: packageJson.version,
|
||||
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
|
||||
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
|
||||
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
|
||||
authProviders: enabledAuthProviders,
|
||||
|
||||
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
|
||||
if (!response.ok) {
|
||||
logger.warn("Unable to send analytics to Umami instance");
|
||||
osPlatform: process.platform,
|
||||
osArch: process.arch,
|
||||
uptimeSeconds: Math.floor(process.uptime()),
|
||||
defaultLocale: cultureSettings.defaultLocale,
|
||||
|
||||
countBoards,
|
||||
countGroups,
|
||||
countApps,
|
||||
countSections,
|
||||
countSearchEngines,
|
||||
countIconRepositories,
|
||||
countIcons,
|
||||
countLayouts,
|
||||
countItemLayouts,
|
||||
countSectionLayouts,
|
||||
countCronJobConfigs,
|
||||
countCustomWidgets,
|
||||
countTrustedCertificates,
|
||||
|
||||
countUsers,
|
||||
countApiKeys,
|
||||
countInvites,
|
||||
countMedias,
|
||||
countSessions,
|
||||
countAccounts,
|
||||
|
||||
countIntegrations: sumGroupedCounts(integrationKinds),
|
||||
countWidgets: sumGroupedCounts(widgetKinds),
|
||||
};
|
||||
|
||||
for (const row of integrationKinds) {
|
||||
properties[`integration_${row.kind}`] = row.count;
|
||||
}
|
||||
for (const row of widgetKinds) {
|
||||
properties[`widget_${row.kind}`] = row.count;
|
||||
}
|
||||
|
||||
client.capture({
|
||||
distinctId: instanceId,
|
||||
event: "server-analytics",
|
||||
properties,
|
||||
});
|
||||
|
||||
await client.flush();
|
||||
logger.info(`Sent analytics to PostHog in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
return "sent";
|
||||
} catch (error) {
|
||||
logger.warn("Failed to send analytics to PostHog", { error });
|
||||
return "failed";
|
||||
}
|
||||
|
||||
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
|
||||
};
|
||||
@@ -24,6 +24,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@homarr/analytics": "workspace:^0.1.0",
|
||||
"@homarr/auth": "workspace:^0.1.0",
|
||||
"@homarr/common": "workspace:^0.1.0",
|
||||
"@homarr/core": "workspace:^0.1.0",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
|
||||
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
|
||||
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
|
||||
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
|
||||
analytics: lazy(() => import("./router/analytics").then((mod) => mod.analyticsRouter)),
|
||||
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
|
||||
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import z from "zod/v4";
|
||||
|
||||
import { sendServerAnalyticsAsync, trackEvent } from "@homarr/analytics";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
|
||||
const getInstanceId = async (): Promise<string | null> => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return null;
|
||||
const settings = await getServerSettingByKeyAsync(db, "analytics");
|
||||
if (!settings.enableGeneral) return null;
|
||||
return settings.instanceId;
|
||||
};
|
||||
|
||||
export const analyticsRouter = createTRPCRouter({
|
||||
sendAnalytics: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) {
|
||||
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "External connections are disabled" });
|
||||
}
|
||||
return { status: await sendServerAnalyticsAsync() };
|
||||
}),
|
||||
|
||||
trackFeature: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
feature: z.string().max(128),
|
||||
properties: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, `feature:${input.feature}`, {
|
||||
...input.properties,
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
|
||||
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
const instanceId = await getInstanceId();
|
||||
if (!instanceId) return;
|
||||
|
||||
trackEvent(instanceId, "user-active", {
|
||||
userId: ctx.session.user.id,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -24,17 +24,18 @@ export const serverSettingsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await updateServerSettingByKeyAsync(
|
||||
ctx.db,
|
||||
input.settingsKey,
|
||||
input.value as ServerSettings[keyof ServerSettings],
|
||||
);
|
||||
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
|
||||
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
|
||||
...current,
|
||||
...input.value,
|
||||
} as ServerSettings[keyof ServerSettings]);
|
||||
}),
|
||||
initSettings: onboardingProcedure
|
||||
.requiresStep("settings")
|
||||
.input(settingsInitSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
|
||||
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
|
||||
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
|
||||
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
|
||||
await nextOnboardingStepAsync(ctx.db, undefined);
|
||||
}),
|
||||
|
||||
@@ -75,9 +75,6 @@ describe("saveSettings", () => {
|
||||
settingsKey: "analytics",
|
||||
value: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -87,9 +84,7 @@ describe("saveSettings", () => {
|
||||
settingKey: "analytics",
|
||||
value: SuperJSON.stringify({
|
||||
enableGeneral: true,
|
||||
enableWidgetData: true,
|
||||
enableIntegrationData: true,
|
||||
enableUserData: true,
|
||||
instanceId: null,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -20,6 +20,7 @@ interface CreateCronJobOptions {
|
||||
runOnStart?: boolean;
|
||||
preventManualExecution?: boolean;
|
||||
preventCustomInterval?: boolean;
|
||||
hidden?: boolean;
|
||||
expectedMaximumDurationInMillis?: number;
|
||||
beforeStart?: () => MaybePromise<void>;
|
||||
}
|
||||
@@ -121,6 +122,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
|
||||
},
|
||||
preventManualExecution: options.preventManualExecution ?? false,
|
||||
preventCustomInterval: options.preventCustomInterval ?? false,
|
||||
hidden: options.hidden ?? false,
|
||||
timezone: creatorOptions.timezone,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -122,15 +122,17 @@ export class JobManager {
|
||||
> {
|
||||
const configurations = await this.db.query.cronJobConfigurations.findMany();
|
||||
|
||||
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
|
||||
const config = configurations.find((config) => config.name === name);
|
||||
return {
|
||||
name,
|
||||
cron: config?.cronExpression ?? job.cronExpression,
|
||||
preventManualExecution: job.preventManualExecution,
|
||||
preventCustomInterval: job.preventCustomInterval,
|
||||
isEnabled: config?.isEnabled ?? true,
|
||||
};
|
||||
});
|
||||
return [...this.jobGroup.getJobRegistry().entries()]
|
||||
.filter(([_, job]) => !job.hidden)
|
||||
.map(([name, job]) => {
|
||||
const config = configurations.find((config) => config.name === name);
|
||||
return {
|
||||
name,
|
||||
cron: config?.cronExpression ?? job.cronExpression,
|
||||
preventManualExecution: job.preventManualExecution,
|
||||
preventCustomInterval: job.preventCustomInterval,
|
||||
isEnabled: config?.isEnabled ?? true,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
|
||||
import { db } from "@homarr/db";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
|
||||
import { createCronJob } from "../lib";
|
||||
|
||||
@@ -10,13 +8,8 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
|
||||
runOnStart: true,
|
||||
preventManualExecution: true,
|
||||
preventCustomInterval: true,
|
||||
hidden: true,
|
||||
}).withCallback(async () => {
|
||||
if (env.NO_EXTERNAL_CONNECTION) return;
|
||||
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
|
||||
|
||||
if (!analyticSetting.enableGeneral) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendServerAnalyticsAsync();
|
||||
});
|
||||
@@ -16,9 +16,7 @@ export type ServerSettingsRecord = Record<(typeof defaultServerSettingsKeys)[num
|
||||
export const defaultServerSettings = {
|
||||
analytics: {
|
||||
enableGeneral: true,
|
||||
enableWidgetData: false,
|
||||
enableIntegrationData: false,
|
||||
enableUserData: false,
|
||||
instanceId: null as string | null,
|
||||
},
|
||||
crawlingAndIndexing: {
|
||||
noIndex: true,
|
||||
|
||||
@@ -4413,20 +4413,8 @@
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"general": {
|
||||
"title": "Send anonymous analytics",
|
||||
"text": "Homarr will send anonymized analytics using the open source software Umami. When enabled, we collect: Homarr version, database type (MySQL/PostgreSQL/SQLite), Docker/Kubernetes status, authentication methods enabled, and the number of each feature. All data is anonymous and aggregated - no personal information, URLs, names, or identifiable data is ever collected. This helps our open source team understand usage patterns and prioritize development."
|
||||
},
|
||||
"widgetData": {
|
||||
"title": "Widget data",
|
||||
"text": "Send which widgets (and their quantity) you have configured. Does not include URLs, names or any other data."
|
||||
},
|
||||
"integrationData": {
|
||||
"title": "Integration data",
|
||||
"text": "Send which integrations (and their quantity) you have configured. Does not include URLs, names or any other data."
|
||||
},
|
||||
"usersData": {
|
||||
"title": "Users data",
|
||||
"text": "Send the amount of users and whether you've activated SSO"
|
||||
"title": "Help us improve Homarr",
|
||||
"text": "This helps us figure out what people actually use so we know where to spend our time. We see stuff like how many widgets or integrations you have, your Homarr version, and what database you're on. That's it — no personal info, no URLs, no names, nothing that could identify you or your setup."
|
||||
}
|
||||
},
|
||||
"crawlingAndIndexing": {
|
||||
|
||||
@@ -3,9 +3,6 @@ import { z } from "zod/v4";
|
||||
export const settingsInitSchema = z.object({
|
||||
analytics: z.object({
|
||||
enableGeneral: z.boolean(),
|
||||
enableWidgetData: z.boolean(),
|
||||
enableIntegrationData: z.boolean(),
|
||||
enableUserData: z.boolean(),
|
||||
}),
|
||||
crawlingAndIndexing: z.object({
|
||||
noIndex: z.boolean(),
|
||||
|
||||
Generated
+357
-793
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -92,7 +92,8 @@ catalog:
|
||||
"@types/video.js": ^7.3.58
|
||||
"@types/ws": ^8.18.1
|
||||
"@types/xml2js": ^0.4.14
|
||||
"@umami/node": ^0.4.0
|
||||
posthog-js: ^1.255.0
|
||||
posthog-node: ^4.17.0
|
||||
"@vitejs/plugin-react": ^6.0.2
|
||||
"@vitest/coverage-v8": ^4.1.8
|
||||
"@vitest/ui": ^4.1.8
|
||||
|
||||
Reference in new issue
Block a user