feat: add analytics (#528)
* feat: add analytics * feat: send data to umami * refactor: fix format * fix: click behavior of switches * refactor: format * chore: rerun ci --------- Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -10,6 +10,7 @@
|
|||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"superjson",
|
"superjson",
|
||||||
"homarr",
|
"homarr",
|
||||||
"trpc"
|
"trpc",
|
||||||
|
"Umami"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
|
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.
|
||||||
|
|
||||||
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
|
## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS
|
||||||
|
|
||||||
### EVERYTHING IS SUBJECT TO CHANGE
|
### EVERYTHING IS SUBJECT TO CHANGE
|
||||||
|
|
||||||
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
|
Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"with-env": "dotenv -e ../../.env --"
|
"with-env": "dotenv -e ../../.env --"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"@homarr/api": "workspace:^0.1.0",
|
"@homarr/api": "workspace:^0.1.0",
|
||||||
"@homarr/auth": "workspace:^0.1.0",
|
"@homarr/auth": "workspace:^0.1.0",
|
||||||
"@homarr/common": "workspace:^0.1.0",
|
"@homarr/common": "workspace:^0.1.0",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { auth } from "@homarr/auth/next";
|
|||||||
import { ModalProvider } from "@homarr/modals";
|
import { ModalProvider } from "@homarr/modals";
|
||||||
import { Notifications } from "@homarr/notifications";
|
import { Notifications } from "@homarr/notifications";
|
||||||
|
|
||||||
|
import { Analytics } from "~/components/layout/analytics";
|
||||||
import { JotaiProvider } from "./_client-providers/jotai";
|
import { JotaiProvider } from "./_client-providers/jotai";
|
||||||
import { NextInternationalProvider } from "./_client-providers/next-international";
|
import { NextInternationalProvider } from "./_client-providers/next-international";
|
||||||
import { AuthProvider } from "./_client-providers/session";
|
import { AuthProvider } from "./_client-providers/session";
|
||||||
@@ -76,6 +77,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
<ColorSchemeScript defaultColorScheme={colorScheme} />
|
||||||
|
<Analytics />
|
||||||
</head>
|
</head>
|
||||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||||
<StackedProvider>
|
<StackedProvider>
|
||||||
|
|||||||
@@ -96,18 +96,23 @@ const SwitchSetting = ({
|
|||||||
title: string;
|
title: string;
|
||||||
text: ReactNode;
|
text: ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
|
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
|
||||||
const handleClick = React.useCallback(() => {
|
const handleClick = React.useCallback(() => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
form.setFieldValue(formKey, !form.values[formKey]);
|
form.setFieldValue(formKey, !form.values[formKey]);
|
||||||
}, [form, formKey]);
|
}, [form, formKey, disabled]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UnstyledButton onClick={handleClick}>
|
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
||||||
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
|
<UnstyledButton style={{ flexGrow: 1 }} onClick={handleClick}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Text fw="bold">{title}</Text>
|
<Text fw="bold">{title}</Text>
|
||||||
<Text c="gray.5">{text}</Text>
|
<Text c="gray.5">{text}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Switch {...form.getInputProps(formKey, { type: "checkbox" })} />
|
</UnstyledButton>
|
||||||
</Group>
|
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
|
||||||
</UnstyledButton>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
14
apps/nextjs/src/components/layout/analytics.tsx
Normal file
14
apps/nextjs/src/components/layout/analytics.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Script from "next/script";
|
||||||
|
|
||||||
|
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
|
||||||
|
import { api } from "@homarr/api/server";
|
||||||
|
|
||||||
|
export const Analytics = async () => {
|
||||||
|
const analytics = await api.serverSettings.getAnalytics();
|
||||||
|
|
||||||
|
if (analytics.enableGeneral) {
|
||||||
|
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <></>;
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"@homarr/redis": "workspace:^0.1.0",
|
"@homarr/redis": "workspace:^0.1.0",
|
||||||
"@homarr/server-settings": "workspace:^0.1.0",
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
|
"@homarr/analytics": "workspace:^0.1.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"superjson": "2.2.1"
|
"superjson": "2.2.1"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
import { iconsUpdaterJob } from "~/jobs/icons-updater";
|
||||||
|
import { analyticsJob } from "./jobs/analytics";
|
||||||
import { queuesJob } from "./jobs/queue";
|
import { queuesJob } from "./jobs/queue";
|
||||||
import { createJobGroup } from "./lib/cron-job/group";
|
import { createJobGroup } from "./lib/cron-job/group";
|
||||||
|
|
||||||
@@ -8,4 +9,5 @@ export const jobs = createJobGroup({
|
|||||||
// This job is used to process queues.
|
// This job is used to process queues.
|
||||||
queues: queuesJob,
|
queues: queuesJob,
|
||||||
iconsUpdater: iconsUpdaterJob,
|
iconsUpdater: iconsUpdaterJob,
|
||||||
|
analytics: analyticsJob,
|
||||||
});
|
});
|
||||||
|
|||||||
29
apps/tasks/src/jobs/analytics.ts
Normal file
29
apps/tasks/src/jobs/analytics.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { sendServerAnalyticsAsync } from "@homarr/analytics";
|
||||||
|
import { db, eq } from "@homarr/db";
|
||||||
|
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||||
|
|
||||||
|
import { EVERY_WEEK } from "~/lib/cron-job/constants";
|
||||||
|
import { createCronJob } from "~/lib/cron-job/creator";
|
||||||
|
import type { defaultServerSettings } from "../../../../packages/server-settings";
|
||||||
|
|
||||||
|
export const analyticsJob = createCronJob(EVERY_WEEK, {
|
||||||
|
runOnStart: true,
|
||||||
|
}).withCallback(async () => {
|
||||||
|
const analyticSetting = await db.query.serverSettings.findFirst({
|
||||||
|
where: eq(serverSettings.settingKey, "analytics"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!analyticSetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(analyticSetting.value);
|
||||||
|
|
||||||
|
if (!value.enableGeneral) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendServerAnalyticsAsync();
|
||||||
|
});
|
||||||
1
packages/analytics/index.ts
Normal file
1
packages/analytics/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./src";
|
||||||
43
packages/analytics/package.json
Normal file
43
packages/analytics/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "@homarr/analytics",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.ts"
|
||||||
|
},
|
||||||
|
"typesVersions": {
|
||||||
|
"*": {
|
||||||
|
"*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"clean": "rm -rf .turbo node_modules",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@homarr/db": "workspace:^0.1.0",
|
||||||
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
|
"@homarr/server-settings": "workspace:^0.1.0",
|
||||||
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"@homarr/eslint-config/base"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"prettier": "@homarr/prettier-config",
|
||||||
|
"dependencies": {
|
||||||
|
"@umami/node": "^0.3.0",
|
||||||
|
"superjson": "2.2.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
packages/analytics/src/constants.ts
Normal file
2
packages/analytics/src/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
||||||
|
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
||||||
2
packages/analytics/src/index.ts
Normal file
2
packages/analytics/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./constants";
|
||||||
|
export * from "./send-server-analytics";
|
||||||
102
packages/analytics/src/send-server-analytics.ts
Normal file
102
packages/analytics/src/send-server-analytics.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { UmamiEventData } from "@umami/node";
|
||||||
|
import { Umami } from "@umami/node";
|
||||||
|
import SuperJSON from "superjson";
|
||||||
|
|
||||||
|
import { count, db, eq } from "@homarr/db";
|
||||||
|
import { integrations, items, serverSettings, users } from "@homarr/db/schema/sqlite";
|
||||||
|
import { logger } from "@homarr/log";
|
||||||
|
import type { defaultServerSettings } from "@homarr/server-settings";
|
||||||
|
|
||||||
|
import { Stopwatch } from "../../common/src";
|
||||||
|
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
|
||||||
|
|
||||||
|
export const sendServerAnalyticsAsync = async () => {
|
||||||
|
const stopWatch = new Stopwatch();
|
||||||
|
const setting = await db.query.serverSettings.findFirst({
|
||||||
|
where: eq(serverSettings.settingKey, "analytics"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
logger.info(
|
||||||
|
"Server does not know the configured state of analytics. No data will be sent. Enable analytics in the settings",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsSettings = SuperJSON.parse<typeof defaultServerSettings.analytics>(setting.value);
|
||||||
|
|
||||||
|
if (!analyticsSettings.enableGeneral) {
|
||||||
|
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const umamiInstance = new Umami();
|
||||||
|
umamiInstance.init({
|
||||||
|
hostUrl: UMAMI_HOST_URL,
|
||||||
|
websiteId: UMAMI_WEBSITE_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendIntegrationDataAsync(umamiInstance, analyticsSettings);
|
||||||
|
await sendWidgetDataAsync(umamiInstance, analyticsSettings);
|
||||||
|
await sendUserDataAsync(umamiInstance, analyticsSettings);
|
||||||
|
|
||||||
|
logger.info(`Sent all analytics in ${stopWatch.getElapsedInHumanWords()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendWidgetDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
|
||||||
|
if (!analyticsSettings.enableWidgetData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const widgetCount = (await db.select({ count: count(items.id) }).from(items))[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const response = await umamiInstance.track("server-widget-data", {
|
||||||
|
countWidgets: widgetCount,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Unable to send track event data to Umami instance");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendUserDataAsync = async (umamiInstance: Umami, analyticsSettings: typeof defaultServerSettings.analytics) => {
|
||||||
|
if (!analyticsSettings.enableUserData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userCount = (await db.select({ count: count(users.id) }).from(users))[0]?.count ?? 0;
|
||||||
|
|
||||||
|
const response = await umamiInstance.track("server-user-data", {
|
||||||
|
countUsers: userCount,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Unable to send track event data to Umami instance");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendIntegrationDataAsync = async (
|
||||||
|
umamiInstance: Umami,
|
||||||
|
analyticsSettings: typeof defaultServerSettings.analytics,
|
||||||
|
) => {
|
||||||
|
if (!analyticsSettings.enableIntegrationData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const integrationKinds = await db
|
||||||
|
.select({ kind: integrations.kind, count: count(integrations.id) })
|
||||||
|
.from(integrations)
|
||||||
|
.groupBy(integrations.kind);
|
||||||
|
|
||||||
|
const map: UmamiEventData = {};
|
||||||
|
|
||||||
|
integrationKinds.forEach((integrationKind) => {
|
||||||
|
map[integrationKind.kind] = integrationKind.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await umamiInstance.track("server-integration-data-kind", map);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn("Unable to send track event data to Umami instance");
|
||||||
|
};
|
||||||
8
packages/analytics/tsconfig.json
Normal file
8
packages/analytics/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@homarr/tsconfig/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||||
|
},
|
||||||
|
"include": ["*.ts", "src"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -2,13 +2,34 @@ import SuperJSON from "superjson";
|
|||||||
|
|
||||||
import { eq } from "@homarr/db";
|
import { eq } from "@homarr/db";
|
||||||
import { serverSettings } from "@homarr/db/schema/sqlite";
|
import { serverSettings } from "@homarr/db/schema/sqlite";
|
||||||
import type { ServerSettings } from "@homarr/server-settings";
|
import { logger } from "@homarr/log";
|
||||||
|
import type { defaultServerSettings, ServerSettings } from "@homarr/server-settings";
|
||||||
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
import { defaultServerSettingsKeys } from "@homarr/server-settings";
|
||||||
import { z } from "@homarr/validation";
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const serverSettingsRouter = createTRPCRouter({
|
export const serverSettingsRouter = createTRPCRouter({
|
||||||
|
// this must be public so anonymous users also get analytics
|
||||||
|
getAnalytics: publicProcedure.query(async ({ ctx }) => {
|
||||||
|
const setting = await ctx.db.query.serverSettings.findFirst({
|
||||||
|
where: eq(serverSettings.settingKey, "analytics"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
logger.info(
|
||||||
|
"Server settings for analytics is currently undefined. Using default values instead. If this persists, there may be an issue with the server settings",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
enableGeneral: true,
|
||||||
|
enableIntegrationData: false,
|
||||||
|
enableUserData: false,
|
||||||
|
enableWidgetData: false,
|
||||||
|
} as (typeof defaultServerSettings)["analytics"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuperJSON.parse<(typeof defaultServerSettings)["analytics"]>(setting.value);
|
||||||
|
}),
|
||||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const settings = await ctx.db.query.serverSettings.findMany();
|
const settings = await ctx.db.query.serverSettings.findMany();
|
||||||
|
|
||||||
|
|||||||
483
pnpm-lock.yaml
generated
483
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user