feat: add Umami Analytics integration and widget (#5362)

Co-authored-by: Josh G <febrile42@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
authored and GitHub committed 2026-05-07 17:50:58 +02:00
1 parent 0937555d49
commit 43cc68bdd4
27 files changed
+1713 -2

No files matched your search

@@ -46,6 +46,7 @@ import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -121,6 +122,7 @@ export const integrationCreators = {
searchCh: SearchChIntegration,
immich: ImmichIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
umami: UmamiIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
+2
View File
@@ -33,6 +33,7 @@ export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { UmamiIntegration } from "./umami/umami-integration";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -68,6 +69,7 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
+1
View File
@@ -16,3 +16,4 @@ export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
export * from "./umami/umami-types";
@@ -0,0 +1,450 @@
import dayjs from "dayjs";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type {
UmamiEventSeries,
UmamiMetricItem,
UmamiPageviewDataPoint,
UmamiPageviews,
UmamiStats,
UmamiVisitorStats,
UmamiWebsite,
} from "./umami-types";
import {
umamiActiveVisitorsSchema,
umamiAuthResponseSchema,
umamiMetricItemSchema,
umamiPageviewDataPointSchema,
umamiPageviewsSchema,
umamiStatsSchema,
umamiWebsiteSchema,
} from "./umami-types";
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
const logger = createLogger({ module: "umami-integration" });
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getWebsitesAsync(): Promise<UmamiWebsite[]> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url("/websites");
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
const json = await response.json();
// Response envelope differs by hosting:
// - Umami self-hosted returns the array directly: [website, ...]
// - Umami Cloud wraps it: { data: [website, ...] }
// Same divergence applies to the /events list responses below.
return Array.isArray(json)
? umamiWebsiteSchema.array().parse(json)
: umamiWebsiteSchema.array().parse((json as { data: unknown }).data);
}
// Server-side timeFrame resolution is intentional. Keeping `timeFrame` (a
// stable string like "7d") in the cache key means all users viewing the same
// dashboard share one Redis cache entry per (integration, website, timeFrame)
// for the 5-minute TTL. Client-supplied startAt/endAt would include a
// per-second `now` and fragment the cache one-per-user-per-second.
//
// Window bounds mirror Umami UI preset semantics, verified against the
// Umami Cloud dashboard for the same site and timeFrame: each "Last N"
// preset includes the in-progress period as one of N + 1 buckets, snapped
// to bucket boundaries.
// - "24h": current hour + 24 prior hours (25-bucket hour grid)
// - "7d": current day + 7 prior days ( 8-bucket day grid)
// - "30d": current day + 30 prior days (31-bucket day grid)
// - "today" / "month" / "lastMonth": calendar-aligned windows.
//
// Caveat: dayjs() uses the server's local timezone. Callers needing user-local
// boundaries (e.g. "today") may see off-by-one behaviour when the server
// runs in UTC but the user is in a different timezone.
private computeTimeRange(timeFrame: string): { startAt: number; endAt: number; unit: string } {
const startOfToday = dayjs().startOf("day");
const endOfToday = dayjs().endOf("day");
switch (timeFrame) {
case "today":
return { startAt: startOfToday.valueOf(), endAt: endOfToday.valueOf(), unit: "hour" };
case "7d":
return {
startAt: startOfToday.subtract(7, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "30d":
return {
startAt: startOfToday.subtract(30, "day").valueOf(),
endAt: endOfToday.valueOf(),
unit: "day",
};
case "month":
return {
startAt: dayjs().startOf("month").valueOf(),
endAt: dayjs().endOf("month").valueOf(),
unit: "day",
};
case "lastMonth": {
const lastMonth = dayjs().subtract(1, "month");
return {
startAt: lastMonth.startOf("month").valueOf(),
endAt: lastMonth.endOf("month").valueOf(),
unit: "day",
};
}
case "24h":
default:
return {
startAt: dayjs().subtract(24, "hour").startOf("hour").valueOf(),
endAt: dayjs().endOf("hour").valueOf(),
unit: "hour",
};
}
}
public async getVisitorStatsAsync(
websiteId: string,
timeFrame = "24h",
eventName?: string,
): Promise<UmamiVisitorStats> {
const authHeaders = await this.getAuthHeadersAsync();
const website = await this.getWebsiteByIdAsync(websiteId, authHeaders);
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const [stats, pageviews, eventMetrics, eventTimeSeries] = await Promise.all([
this.getWebsiteStatsAsync(website.id, startAt, endAt, authHeaders),
this.getWebsitePageviewsAsync(website.id, startAt, endAt, authHeaders, unit),
eventName ? this.getWebsiteEventMetricsAsync(website.id, startAt, endAt, authHeaders) : Promise.resolve(null),
eventName
? this.getWebsiteEventTimeSeriesAsync(website.id, startAt, endAt, unit, eventName, authHeaders)
: Promise.resolve(null),
]);
const eventCount = eventName && eventMetrics ? (eventMetrics.find(({ x }) => x === eventName)?.y ?? 0) : undefined;
const eventByTimestamp = new Map<number, number>(
(eventTimeSeries ?? []).map(({ x, y }) => [this.parseUmamiDate(x), y]),
);
const dataPoints = pageviews.sessions.map((point) => ({
timestamp: point.x,
visitors: point.y,
...(eventTimeSeries ? { events: eventByTimestamp.get(this.parseUmamiDate(point.x)) ?? 0 } : {}),
}));
return {
domain: website.domain,
websiteName: website.name,
websiteId: website.id,
totalVisitors: stats.visitors,
totalPageviews: stats.pageviews,
totalVisits: stats.visits,
bounceRate: stats.visits > 0 ? Math.round((stats.bounces / stats.visits) * 100) : 0,
avgDuration: stats.visits > 0 ? Math.round(stats.totaltime / stats.visits) : 0,
dataPoints,
timeFrame,
eventCount,
};
}
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
}
return [...names].sort();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
const authHeaders = await this.getAuthHeadersAsync();
const url = this.url(`/websites/${websiteId}/active`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load active visitors", { status: response.status, websiteId });
return 0;
}
return umamiActiveVisitorsSchema.parse(await response.json()).visitors;
}
/**
* Umami self-hosted returns "YYYY-MM-DD HH:MM:SS"; Umami Cloud returns ISO 8601.
* Normalise both to epoch ms so timestamps from /pageviews and /events join.
*/
private parseUmamiDate(dateString: string): number {
return new Date(dateString.includes("T") ? dateString : `${dateString.replace(" ", "T")}Z`).getTime();
}
private async getWebsiteByIdAsync(websiteId: string, authHeaders: Record<string, string>): Promise<UmamiWebsite> {
const url = this.url(`/websites/${websiteId}`);
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) throw new ResponseError(response);
return umamiWebsiteSchema.parse(await response.json());
}
public async getTopPagesAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "path", authHeaders, limit);
}
public async getTopReferrersAsync(websiteId: string, timeFrame: string, limit: number): Promise<UmamiMetricItem[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt } = this.computeTimeRange(timeFrame);
return this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "referrer", authHeaders, limit);
}
public async getMultiEventTimeSeriesAsync(
websiteId: string,
timeFrame: string,
eventNames: string[],
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
return results;
}
private async getWebsiteEventMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiMetricItem[]> {
return await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders);
}
private async getWebsiteMetricsAsync(
websiteId: string,
startAt: number,
endAt: number,
type: "event" | "path" | "referrer",
authHeaders: Record<string, string>,
limit?: number,
): Promise<UmamiMetricItem[]> {
const params: Record<string, string> = {
startAt: startAt.toString(),
endAt: endAt.toString(),
type,
};
if (limit !== undefined) params.limit = limit.toString();
const url = this.url(`/websites/${websiteId}/metrics`, params);
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load website metrics", { status: response.status, websiteId, type });
return [];
}
const json = await response.json();
if (Array.isArray(json)) return umamiMetricItemSchema.array().parse(json);
logger.warn("Unexpected metrics response shape", { websiteId, type });
return [];
}
private async getWebsiteEventTimeSeriesAsync(
websiteId: string,
startAt: number,
endAt: number,
unit: string,
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
if (rawArray.length === 0) return undefined;
const firstItem = rawArray[0] as Record<string, unknown>;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
}
return undefined;
}
private async getWebsiteStatsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
): Promise<UmamiStats> {
const url = this.url(`/websites/${websiteId}/stats`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiStatsSchema.parse(await response.json());
}
private async getWebsitePageviewsAsync(
websiteId: string,
startAt: number,
endAt: number,
authHeaders: Record<string, string>,
unit = "hour",
): Promise<UmamiPageviews> {
const url = this.url(`/websites/${websiteId}/pageviews`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
throw new ResponseError(response);
}
return umamiPageviewsSchema.parse(await response.json());
}
private async getAuthHeadersAsync(): Promise<Record<string, string>> {
if (this.hasSecretValue("apiKey")) {
return { "x-umami-api-key": this.getSecretValue("apiKey") };
}
const token = await this.getJwtTokenAsync();
return { Authorization: `Bearer ${token}` };
}
private async getJwtTokenAsync(): Promise<string> {
const url = this.url("/auth/login");
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: this.getSecretValue("username"),
password: this.getSecretValue("password"),
}),
});
if (!response.ok) throw new ResponseError(response);
return umamiAuthResponseSchema.parse(await response.json()).token;
}
}
@@ -0,0 +1,84 @@
import { z } from "zod/v4";
export const umamiWebsiteSchema = z.object({
id: z.string(),
name: z.string(),
domain: z.string(),
});
export type UmamiWebsite = z.infer<typeof umamiWebsiteSchema>;
export const umamiAuthResponseSchema = z.object({
token: z.string(),
});
export type UmamiAuthResponse = z.infer<typeof umamiAuthResponseSchema>;
export const umamiStatsSchema = z.object({
pageviews: z.number(),
visitors: z.number(),
visits: z.number(),
bounces: z.number(),
totaltime: z.number(),
});
export type UmamiStats = z.infer<typeof umamiStatsSchema>;
export const umamiPageviewDataPointSchema = z.object({
x: z.string(),
y: z.number(),
});
export type UmamiPageviewDataPoint = z.infer<typeof umamiPageviewDataPointSchema>;
export const umamiPageviewsSchema = z.object({
pageviews: z.array(umamiPageviewDataPointSchema),
sessions: z.array(umamiPageviewDataPointSchema),
});
export type UmamiPageviews = z.infer<typeof umamiPageviewsSchema>;
export const umamiMetricItemSchema = z.object({
// x can be null for direct traffic (no referrer) in the referrers endpoint
x: z
.string()
.nullable()
.transform((value) => value ?? ""),
y: z.number(),
});
export type UmamiMetricItem = z.infer<typeof umamiMetricItemSchema>;
export const umamiEventRecordSchema = z.object({
id: z.string(),
eventName: z.string(),
createdAt: z.string(),
});
export type UmamiEventRecord = z.infer<typeof umamiEventRecordSchema>;
export const umamiActiveVisitorsSchema = z.object({
visitors: z.number(),
});
export type UmamiActiveVisitors = z.infer<typeof umamiActiveVisitorsSchema>;
// Internal aggregated type returned by the integration — not a direct API response
export interface UmamiEventSeries {
eventName: string;
dataPoints: UmamiPageviewDataPoint[];
}
export interface UmamiVisitorStats {
domain: string;
websiteId: string;
websiteName: string;
totalVisitors: number;
totalPageviews: number;
totalVisits: number;
bounceRate: number;
avgDuration: number;
dataPoints: { timestamp: string; visitors: number; events?: number }[];
timeFrame: string;
eventCount?: number;
}