Merge pull request #1145 from MBombeck/perf/dashboard-client

perf: faster charts and insights, no sidebar scrollbar flash, Umami events delivered
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-04 17:33:51 +02:00
commit 006679ed9e
33 files changed
+1458 -393

No files matched your search

@@ -4,6 +4,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import {
resolveUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
@@ -12,30 +16,6 @@ function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates));
} catch {
return [];
}
}
function resolveAppUrl(request: NextRequest): URL {
const configured =
envValue("APP_URL") ??
@@ -63,7 +43,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Umami script URL and website ID must be configured", 422);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
// The collect path is compiled into the operator's tracker build
// (`COLLECT_API_ENDPOINT`), so the test posts where the tracker would.
const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl);
if (collectPath === null) {
return apiError("Umami script could not be fetched", 502);
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl, collectPath);
if (targetUrls.length === 0) {
return apiError("Umami script URL is invalid", 422);
}
+8 -39
View File
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { safeFetch } from "@/lib/safe-fetch";
import { fetchUmamiScript } from "@/lib/monitoring/umami";
export const dynamic = "force-dynamic";
@@ -26,35 +26,14 @@ export const GET = apiHandler(async () => {
});
}
// Fail-soft: an unreachable / slow / rebinding-refused upstream must
// degrade to the noop script, not 500 the proxy route — `safeFetch`
// THROWS on its 15 s timeout and on the connect-time public-host
// refusal, and an uncaught throw here surfaced as a 500 on every
// mount whenever the operator's Umami host was down or unresolvable.
let response: Response;
try {
response = await safeFetch(
settings.umamiScriptUrl,
{
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 between
// the input-time `isPublicUrl` accept and the socket connect.
{ requirePublicHost: true },
);
} catch (err) {
// Fail-soft stays a 200, but the failure must stay observable:
// a proper action name keeps the degraded state visible on the
// wide-event dashboards instead of hiding behind a bare meta flag.
// Fail-soft: an unreachable / slow / rebinding-refused upstream degrades
// to the noop script, never a 500 on every mount. The failure stays
// observable under its own action name on the wide-event dashboards.
const fetched = await fetchUmamiScript(settings.umamiScriptUrl);
if (!fetched.ok) {
annotate({
action: { name: "monitoring.umami_script.fetch_failed" },
meta: {
reason:
err instanceof Error
? err.message.slice(0, 200)
: String(err).slice(0, 200),
},
meta: { reason: fetched.reason },
});
return new NextResponse(NOOP_SCRIPT, {
status: 200,
@@ -64,18 +43,8 @@ export const GET = apiHandler(async () => {
},
});
}
if (!response.ok) {
return new NextResponse(NOOP_SCRIPT, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
});
}
const script = await response.text();
return new NextResponse(script, {
return new NextResponse(fetched.script, {
status: 200,
headers: {
"Content-Type": "application/javascript; charset=utf-8",
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { resetUmamiCollectPathCache } from "@/lib/monitoring/umami";
import { checkRateLimit } from "@/lib/rate-limit";
const SCRIPT_URL = "https://analytics.example.com/script.js";
/** A built tracker's endpoint template, as Umami compiles it. */
function tracker(collectPath: string): string {
return `!function(){const R=\`\${(x||""||u.src.split("/").slice(0,-1).join("/")).replace(/\\/$/,"")}${collectPath}\`;}();`;
}
function post(path: string[], ip = "203.0.113.11") {
return POST(
new NextRequest(`http://localhost/api/monitoring/umami/${path.join("/")}`, {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: { "content-type": "application/json", "x-real-ip": ip },
}),
{ params: Promise.resolve({ path }) },
);
}
/** Upstream: the tracker script on GET, `status` on the event POST. */
function upstream(collectPath: string, status = 202) {
vi.mocked(fetch).mockImplementation(async (input, init) => {
const url = String(input instanceof Request ? input.url : input);
if (url === SCRIPT_URL && (!init?.method || init.method === "GET")) {
return new Response(tracker(collectPath), { status: 200 });
}
return new Response(null, { status });
});
}
function eventPosts(): string[] {
return vi
.mocked(fetch)
.mock.calls.filter(([, init]) => init?.method === "POST")
.map(([input]) => String(input));
}
beforeEach(() => {
vi.resetAllMocks();
resetUmamiCollectPathCache();
vi.stubGlobal("fetch", vi.fn());
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: SCRIPT_URL,
umamiWebsiteId: "site-1",
} as never);
});
describe("POST /api/monitoring/umami/[...path]", () => {
it("rate-limits the public Umami proxy by client IP before anything else", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await post(["api", "send"], "203.0.113.10");
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("forwards a default build's /api/send to the Umami host", async () => {
upstream("/api/send");
const response = await post(["api", "send"]);
expect(response.status).toBe(202);
expect(eventPosts()).toEqual(["https://analytics.example.com/api/send"]);
});
it("forwards a renamed collect endpoint to the same path on the Umami host", async () => {
// A build with COLLECT_API_ENDPOINT=/api/insight posts to
// `<data-host-url>/api/insight`. Before the proxy prefix, that landed on
// the app's own origin and 404'd with a full page on every page view.
upstream("/api/insight");
const response = await post(["api", "insight"]);
expect(response.status).toBe(202);
expect(eventPosts()).toEqual(["https://analytics.example.com/api/insight"]);
});
it("refuses any path that is not the tracker's collect path, with an empty body", async () => {
upstream("/api/insight");
const response = await post(["api", "websites"]);
expect(response.status).toBe(404);
expect(await response.text()).toBe("");
expect(eventPosts()).toEqual([]);
});
it("learns the collect path once, not on every event", async () => {
upstream("/api/insight");
await post(["api", "insight"]);
await post(["api", "insight"]);
const scriptFetches = vi
.mocked(fetch)
.mock.calls.filter(([input]) => String(input) === SCRIPT_URL);
expect(scriptFetches).toHaveLength(1);
expect(eventPosts()).toHaveLength(2);
});
it("answers an empty 502 when the tracker cannot be read", async () => {
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 500 }));
const response = await post(["api", "send"]);
expect(response.status).toBe(502);
expect(await response.text()).toBe("");
expect(eventPosts()).toEqual([]);
});
it("answers 204 without touching the network when Umami is off", async () => {
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: false,
umamiScriptUrl: SCRIPT_URL,
umamiWebsiteId: "site-1",
} as never);
const response = await post(["api", "send"]);
expect(response.status).toBe(204);
expect(fetch).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import {
resolveUmamiCollectPath,
resolveUmamiSendUrls,
} from "@/lib/monitoring/umami";
import { checkRateLimit } from "@/lib/rate-limit";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
/** An empty answer: a refused event must not cost the tracker a 404 page. */
function empty(status: number): NextResponse {
return new NextResponse(null, { status });
}
/**
* Same-origin proxy for the browser's Umami tracker.
*
* The tracker is loaded with `data-host-url` set to `UMAMI_PROXY_PREFIX`, so
* it posts to `<prefix><collect path>`, where the collect path is whatever
* the operator's Umami build calls its endpoint (`/api/send` by default).
* The path after the prefix must be exactly the collect path compiled into
* the configured tracker; anything else is refused, so the proxy forwards
* events and nothing else to the operator's host.
*/
export const POST = apiHandler(
async (
request: NextRequest,
context: { params: Promise<{ path: string[] }> },
) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (
!settings.umamiEnabled ||
!settings.umamiScriptUrl ||
!settings.umamiWebsiteId
) {
return empty(204);
}
const { path } = await context.params;
const requested = `/${path.join("/")}`;
const collectPath = await resolveUmamiCollectPath(settings.umamiScriptUrl);
if (collectPath === null) {
annotate({
action: { name: "umami.proxy.script_unavailable" },
});
return empty(502);
}
if (requested !== collectPath) {
annotate({
action: { name: "umami.proxy.path_refused" },
meta: { requested: requested.slice(0, 128), collectPath },
});
return empty(404);
}
const targetUrls = resolveUmamiSendUrls(
settings.umamiScriptUrl,
collectPath,
);
if (targetUrls.length === 0) return empty(204);
const body = await request.arrayBuffer();
// Cap the proxied body at 64 KB.
if (body.byteLength > 65536) return empty(413);
let lastStatus = 404;
for (const targetUrl of targetUrls) {
let upstream: Response;
try {
upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent":
request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
// Operator-configured Umami host: pin the connect-time IP.
{ requirePublicHost: true },
);
} catch {
annotate({ action: { name: "umami.proxy.upstream_failed" } });
return empty(502);
}
lastStatus = upstream.status;
// Only a path miss moves on to the next candidate.
if (upstream.status !== 404) return empty(upstream.status);
}
return empty(lastStatus);
},
);
-103
View File
@@ -1,103 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// safeFetch's requirePublicHost path runs through undici's own `fetch`
// (version-locked with its dispatcher). Delegate it to the global `fetch`
// stub these tests install so the existing interception still applies.
vi.mock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
fetch: (input: unknown, init?: unknown) =>
(globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)(
input,
init,
),
};
});
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/monitoring-settings", () => ({
getPublicMonitoringSettings: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
import { POST } from "../route";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", vi.fn());
});
describe("POST /api/send", () => {
it("rate-limits the public Umami proxy by client IP", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-forwarded-for": "203.0.113.10",
},
}),
);
expect(response.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
"umami-proxy:203.0.113.10",
120,
60 * 1000,
);
expect(getPublicMonitoringSettings).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
});
it("continues to proxy when the request is within quota", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 119,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getPublicMonitoringSettings).mockResolvedValue({
umamiEnabled: true,
umamiScriptUrl: "https://analytics.example.com/script.js",
umamiWebsiteId: "site-1",
} as never);
vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 202 }));
const response = await POST(
new NextRequest("http://localhost/api/send", {
method: "POST",
body: JSON.stringify({ type: "event" }),
headers: {
"content-type": "application/json",
"x-real-ip": "203.0.113.11",
},
}),
);
expect(response.status).toBe(202);
expect(fetch).toHaveBeenCalledWith(
"https://analytics.example.com/api/send",
expect.objectContaining({ method: "POST" }),
);
});
});
-104
View File
@@ -1,104 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getPublicMonitoringSettings } from "@/lib/monitoring-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { isPublicUrl } from "@/lib/validations/notifications";
import { safeFetch } from "@/lib/safe-fetch";
export const dynamic = "force-dynamic";
// Use the canonical `isPublicUrl` guard rather than a hand-rolled
// allowlist. The previous local implementation predated the central
// helper and was missing CGNAT (100.64/10), IPv6 ULA (fc00::/7), the
// octal/hex/decimal IPv4 alt-notations, and the IPv4-mapped IPv6
// shapes — every one of which the shared helper catches. Dev mode is
// no longer special-cased here: a localhost-or-RFC1918 Umami URL was
// never a valid configuration anyway (the proxy ships the request to
// the configured host with the visitor's user-agent + body, which
// only makes sense against an externally-reachable analytics target).
function isAllowedUmamiUrl(url: string): boolean {
return isPublicUrl(url);
}
function resolveUmamiSendUrls(scriptUrl: string | null): string[] {
if (!scriptUrl) return [];
try {
const parsed = new URL(scriptUrl);
const origin = parsed.origin;
const pathSegments = parsed.pathname.split("/").filter(Boolean);
const segments = [...pathSegments];
// Remove script file segment (e.g. script.js, umami.js) if present.
if (segments.length > 0 && segments[segments.length - 1]?.includes(".")) {
segments.pop();
}
const prefix = segments.length > 0 ? `/${segments.join("/")}` : "";
const candidates = [
`${origin}${prefix}/api/send`,
`${origin}/api/send`,
`${origin}/umami/api/send`,
];
return Array.from(new Set(candidates.filter(isAllowedUmamiUrl)));
} catch {
return [];
}
}
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "umami.proxy" } });
const ip = getClientIp(request) ?? "unknown";
const rl = await checkRateLimit(`umami-proxy:${ip}`, 120, 60 * 1000);
if (!rl.allowed) return apiError("Rate limit exceeded", 429);
const settings = await getPublicMonitoringSettings();
if (!settings.umamiEnabled) {
return NextResponse.json({}, { status: 204 });
}
const targetUrls = resolveUmamiSendUrls(settings.umamiScriptUrl);
if (targetUrls.length === 0 || !settings.umamiWebsiteId) {
return NextResponse.json({}, { status: 204 });
}
const body = await request.arrayBuffer();
// Limit proxy body size to 64 KB to prevent abuse
if (body.byteLength > 65536) {
return NextResponse.json({}, { status: 413 });
}
let lastResponseStatus = 404;
for (const targetUrl of targetUrls) {
const upstream = await safeFetch(
targetUrl,
{
method: "POST",
headers: {
"content-type":
request.headers.get("content-type") || "application/json",
"user-agent": request.headers.get("user-agent") || "healthlog-proxy",
},
body,
cache: "no-store",
},
// Operator-configured Umami host — pin the connect-time IP.
{ requirePublicHost: true },
);
lastResponseStatus = upstream.status;
// Try next candidate only for path misses.
if (upstream.status === 404) {
continue;
}
return new NextResponse(null, { status: upstream.status });
}
return new NextResponse(null, { status: lastResponseStatus });
});