Files
HealthLog/src/lib/monitoring/umami.ts
T
Marc-André Bombeck 1b1da652d3 fix(monitoring): route Umami events to the endpoint the tracker was built with
The tracker posts to `${data-host-url}${collect path}`, and the collect
path is compiled into the operator's Umami build: /api/send by default,
anything else when the instance sets COLLECT_API_ENDPOINT. The tag
pointed data-host-url at the app origin and the proxy listened only on
/api/send, so a renamed endpoint sent every page view and event to an
app path that does not exist and got a full 404 page back each time.

Point data-host-url at /api/monitoring/umami and answer every path under
it with one proxy route. The route reads the collect path from the
configured tracker (cached for an hour), forwards only that path to the
Umami host, and answers anything else, or an unreadable tracker, with an
empty response. The admin test event posts to the same learnt path. The
send-URL resolver lives in one module instead of two copies.
2026-10-04 15:13:52 +02:00

114 lines
3.9 KiB
TypeScript

import { safeFetch } from "@/lib/safe-fetch";
import { isPublicUrl } from "@/lib/validations/notifications";
import { UMAMI_DEFAULT_COLLECT_PATH } from "./umami-paths";
/**
* The endpoint template in a built tracker:
* `` `${(hostUrl || …).replace(/\/$/,"")}/api/send` ``. The capture is the
* path the build appends to the host.
*/
const COLLECT_PATH_PATTERN =
/\.replace\(\/\\\/\$\/,\s*(?:""|'')\)\}(\/[\w\-./~]{1,128})`/;
/** A collect path is a plain absolute path: no traversal, no query. */
function isPlainPath(path: string): boolean {
return /^\/[\w\-./~]{1,128}$/.test(path) && !path.split("/").includes("..");
}
/**
* The collect path compiled into a tracker build, or Umami's default when
* the build does not carry a recognisable endpoint template.
*/
export function extractUmamiCollectPath(script: string): string {
const match = COLLECT_PATH_PATTERN.exec(script)?.[1];
return match && isPlainPath(match) ? match : UMAMI_DEFAULT_COLLECT_PATH;
}
/**
* Upstream URLs to try for an event, in order: the collect path under the
* script's own directory (Umami served below a path prefix), at the host
* root, and under the conventional `/umami` mount. Only public URLs survive.
*/
export function resolveUmamiSendUrls(
scriptUrl: string | null,
collectPath: string = UMAMI_DEFAULT_COLLECT_PATH,
): string[] {
if (!scriptUrl || !isPlainPath(collectPath)) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const segments = parsed.pathname.split("/").filter(Boolean);
// Drop the script file itself (script.js, umami.js, …).
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}${collectPath}`,
`${origin}${collectPath}`,
`${origin}/umami${collectPath}`,
];
return Array.from(new Set(candidates.filter((url) => isPublicUrl(url))));
} catch {
return [];
}
}
const COLLECT_PATH_TTL_MS = 60 * 60 * 1000;
const collectPathCache = new Map<string, { path: string; expiresAt: number }>();
export type UmamiScriptFetch =
{ ok: true; script: string } | { ok: false; reason: string };
/**
* Fetch the operator's tracker. Fail-soft: an unreachable, slow, refused or
* non-2xx upstream comes back as `{ ok: false, reason }`, never a throw. A
* successful fetch also records the build's collect path, so the event proxy
* learns it without a fetch of its own.
*/
export async function fetchUmamiScript(
scriptUrl: string,
): Promise<UmamiScriptFetch> {
try {
const response = await safeFetch(
scriptUrl,
{ next: { revalidate: 3600 } },
// Operator-configured host: pin the connect-time IP so a low-TTL DNS
// record cannot rebind the fetch at an internal range.
{ requirePublicHost: true },
);
if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
const script = await response.text();
collectPathCache.set(scriptUrl, {
path: extractUmamiCollectPath(script),
expiresAt: Date.now() + COLLECT_PATH_TTL_MS,
});
return { ok: true, script };
} catch (err) {
return {
ok: false,
reason: (err instanceof Error ? err.message : String(err)).slice(0, 200),
};
}
}
/**
* The collect path of the configured tracker, from the cache when fresh,
* otherwise by fetching the script. `null` when the script cannot be read,
* so the caller can refuse rather than guess.
*/
export async function resolveUmamiCollectPath(
scriptUrl: string,
): Promise<string | null> {
const cached = collectPathCache.get(scriptUrl);
if (cached && cached.expiresAt > Date.now()) return cached.path;
const fetched = await fetchUmamiScript(scriptUrl);
return fetched.ok ? extractUmamiCollectPath(fetched.script) : null;
}
/** Test seam: forget every learnt collect path. */
export function resetUmamiCollectPathCache(): void {
collectPathCache.clear();
}