Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add analytics #528

Merged
merged 7 commits into from
May 26, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"cSpell.words": [
"superjson",
"homarr",
"trpc"
"trpc",
"Umami"
]
}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# THIS PROJECT IS STILL UNSTABLE AND WE DO NOT PROVIDE ANY SUPPORT FOR ISSUES THAT OCCURE.

## PLEASE DO NOT OPEN ANY ISSUES OR DISCUSSIONS

### EVERYTHING IS SUBJECT TO CHANGE

Please use [this](https://github.com/ajnart/homarr) version of Homarr when you want to use it
1 change: 1 addition & 0 deletions apps/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"with-env": "dotenv -e ../../.env --"
},
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/api": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
Expand Down
2 changes: 2 additions & 0 deletions apps/nextjs/src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { auth } from "@homarr/auth/next";
import { ModalProvider } from "@homarr/modals";
import { Notifications } from "@homarr/notifications";

import { Analytics } from "~/components/layout/analytics";
import { JotaiProvider } from "./_client-providers/jotai";
import { NextInternationalProvider } from "./_client-providers/next-international";
import { AuthProvider } from "./_client-providers/session";
Expand Down Expand Up @@ -76,6 +77,7 @@ export default function Layout(props: { children: React.ReactNode; params: { loc
<html lang="en" suppressHydrationWarning>
<head>
<ColorSchemeScript defaultColorScheme={colorScheme} />
<Analytics />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<StackedProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,23 @@ const SwitchSetting = ({
title: string;
text: ReactNode;
}) => {
const disabled = formKey !== "enableGeneral" && !form.values.enableGeneral;
const handleClick = React.useCallback(() => {
if (disabled) {
return;
}
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey]);
}, [form, formKey, disabled]);

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}>
<Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text>
</Stack>
<Switch {...form.getInputProps(formKey, { type: "checkbox" })} />
</Group>
</UnstyledButton>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
</Group>
);
};
14 changes: 14 additions & 0 deletions apps/nextjs/src/components/layout/analytics.tsx
Original file line number Diff line number Diff line change
@@ -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 <></>;
};
1 change: 1 addition & 0 deletions apps/tasks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@homarr/redis": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@homarr/analytics": "workspace:^0.1.0",
"dotenv": "^16.4.5",
"node-cron": "^3.0.3",
"superjson": "2.2.1"
Expand Down
2 changes: 2 additions & 0 deletions apps/tasks/src/jobs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { iconsUpdaterJob } from "~/jobs/icons-updater";
import { analyticsJob } from "./jobs/analytics";
import { queuesJob } from "./jobs/queue";
import { createJobGroup } from "./lib/cron-job/group";

Expand All @@ -8,4 +9,5 @@ export const jobs = createJobGroup({
// This job is used to process queues.
queues: queuesJob,
iconsUpdater: iconsUpdaterJob,
analytics: analyticsJob,
});
29 changes: 29 additions & 0 deletions apps/tasks/src/jobs/analytics.ts
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions packages/analytics/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./src";
43 changes: 43 additions & 0 deletions packages/analytics/package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 2 additions & 0 deletions packages/analytics/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
2 changes: 2 additions & 0 deletions packages/analytics/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./constants";
export * from "./send-server-analytics";
102 changes: 102 additions & 0 deletions packages/analytics/src/send-server-analytics.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 8 additions & 0 deletions packages/analytics/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}
25 changes: 23 additions & 2 deletions packages/api/src/router/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,34 @@ import SuperJSON from "superjson";

import { eq } from "@homarr/db";
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 { z } from "@homarr/validation";

import { createTRPCRouter, protectedProcedure } from "../trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";

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 }) => {
const settings = await ctx.db.query.serverSettings.findMany();

Expand Down
Loading
Loading