Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
61 changes: 61 additions & 0 deletions cueweb/app/api/track/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { NextRequest, NextResponse } from "next/server";

import MetricsService from "@/lib/metrics-service";
import { extractUser } from "@/lib/track-user";

// POST /api/track - usage beacon from the client. The client sends only the
// kind + a coarse name; the USER is resolved server-side from the session, so
// it can't be spoofed. Increments the matching Prometheus counter.
// { kind: "page", name: "<route-or-page>" }
// { kind: "action", name: "<action-key>" }
// { kind: "facility", name: "<facility>" }
// { kind: "login" }
export async function POST(request: NextRequest): Promise<NextResponse> {
let body: { kind?: string; name?: string };
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const kind = String(body?.kind ?? "");
const name = String(body?.name ?? "").slice(0, 64); // cap length defensively
const user = await extractUser(request);
const metrics = MetricsService.getInstance();

switch (kind) {
case "page":
metrics.recordPageView(user, name);
break;
case "action":
metrics.recordAction(user, name);
break;
case "facility":
metrics.recordFacility(user, name || "unknown");
break;
case "login":
metrics.recordLogin(user);
break;
default:
return NextResponse.json({ error: "Unknown kind" }, { status: 400 });
}

// 204: fire-and-forget beacon, nothing to return.
return new NextResponse(null, { status: 204 });
}
2 changes: 2 additions & 0 deletions cueweb/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { KeyboardShortcuts } from "@/components/ui/shortcuts-overlay";
import { AboutDialog } from "@/components/ui/about-dialog";
import { PluginSettingsDialog } from "@/components/ui/settings-dialog";
import { ToastHost } from "@/components/ui/toast-host";
import { UsageTracker } from "@/components/ui/usage-tracker";

export const metadata: Metadata = {
title: "CueWeb",
Expand All @@ -53,6 +54,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<MobileNavSheet />
<PluginSettingsDialog />
<ToastHost />
<UsageTracker />
</AppSessionProvider>
</ThemeProvider>
<JobSubscriptionPoller />
Expand Down
3 changes: 3 additions & 0 deletions cueweb/app/utils/api_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { handleError } from "./notify_utils";
import { trackActionEndpoint } from "./usage_tracking";

/************************************************************/
// Client-safe API helpers (same-origin calls to this app's own /api routes).
Expand All @@ -28,6 +29,8 @@ import { handleError } from "./notify_utils";
// Helper function to access a post API with a success or failure returned and handle any errors.
// Actions follow this format: post to the API and see if the action was successful
export async function accessActionApi(endpoint: string, body: string | string[]): Promise<{ success?: boolean; error?: string }> {
// Usage metric: record the user action (best-effort, fire-and-forget).
trackActionEndpoint(endpoint);
// Default to a same-origin relative URL when NEXT_PUBLIC_URL is empty
// or unset. The API routes are mounted by this same Next.js app, so
// the browser can reach them at whatever origin the page loaded from
Expand Down
32 changes: 32 additions & 0 deletions cueweb/app/utils/gateway_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { NextResponse } from "next/server";

import { handleError } from "./notify_utils";
import { getRequestFacilityTargetWithOverrides } from "@/lib/facility-server";
import MetricsService from "@/lib/metrics-service";

interface JwtParams {
sub: string;
Expand Down Expand Up @@ -115,22 +116,53 @@ export async function fetchObjectFromRestGateway(
}

// Centralized route handler to fetch data and handle errors.
// Shorten a gRPC endpoint ("/job.JobInterface/GetJobs") to a compact,
// bounded metric label ("job.getjobs") so the API usage counter stays small.
function shortEndpoint(endpoint: string): string {
const parts = endpoint.replace(/^\//, "").split("/");
const iface = (parts[0] ?? "").split(".")[0] || "unknown";
const method = (parts[1] ?? "").toLowerCase() || "unknown";
return `${iface}.${method}`;
}

export async function handleRoute(
method: string,
endpoint: string,
body: string,
log = false,
): Promise<NextResponse> {
// Usage metrics: time the call and record it per (short endpoint, status
// class). Best-effort - metric failures must never affect the response.
const startedAt = Date.now();
const shortName = shortEndpoint(endpoint);
let observed = false;
const observe = (status: number) => {
if (observed) return;
observed = true;
try {
MetricsService.getInstance().recordApiRequest(
shortName,
status,
(Date.now() - startedAt) / 1000,
);
} catch {
// ignore - metrics must never affect the response
}
};

try {
const response = await fetchObjectFromRestGateway(endpoint, method, body);
const responseData = await response.json();

if (responseData.error) {
observe(response.status >= 400 ? response.status : 500);
throw new Error(responseData.error);
}

observe(response.status);
return NextResponse.json({ data: responseData.data }, { status: response.status });
} catch (error) {
observe(500);
handleError(error);
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
}
Expand Down
99 changes: 99 additions & 0 deletions cueweb/app/utils/usage_tracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Client-side usage beacons. Each call posts a tiny payload to /api/track,
// which resolves the USER server-side (from the session) and increments a
// Prometheus counter. Fire-and-forget; never throws; opt out at build time with
// NEXT_PUBLIC_USAGE_TRACKING=off.

const ENABLED =
typeof window !== "undefined" &&
(process.env.NEXT_PUBLIC_USAGE_TRACKING ?? "on").toLowerCase() !== "off";

function beacon(payload: { kind: string; name?: string }): void {
if (!ENABLED) return;
try {
const body = JSON.stringify(payload);
// sendBeacon survives navigation; fall back to keepalive fetch.
if (navigator.sendBeacon) {
navigator.sendBeacon("/api/track", new Blob([body], { type: "application/json" }));
} else {
void fetch("/api/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
});
}
} catch {
// ignore - usage tracking must never affect the UI
}
}

// Map a Next.js pathname to a coarse, bounded page/module name (must match the
// ALLOWED_PAGES allow-list in lib/metrics-service.ts; anything else -> "other").
export function pageNameForPath(pathname: string): string {
if (!pathname || pathname === "/") return "monitor-jobs";
if (pathname.startsWith("/dashboard")) return "dashboard";
if (pathname.startsWith("/monitor-cue")) return "monitor-cue";
if (pathname.startsWith("/split")) return "monitor-jobs";
if (pathname.startsWith("/hosts/")) return "host-details";
if (pathname.startsWith("/hosts")) return "monitor-hosts";
if (pathname.startsWith("/jobs/")) return "job-details";
if (pathname.startsWith("/frames/")) return "frame-log";
if (pathname.startsWith("/allocations")) return "allocations";
if (pathname.startsWith("/limits")) return "limits";
if (pathname.startsWith("/redirect")) return "redirect";
if (pathname.startsWith("/services")) return "services";
if (pathname.startsWith("/shows")) return "shows";
if (pathname.startsWith("/stuck-frames")) return "stuck-frames";
if (pathname.startsWith("/subscription-graphs")) return "subscription-graphs";
if (pathname.startsWith("/subscriptions")) return "subscriptions";
if (pathname.startsWith("/cuesubmit")) return "cuesubmit";
if (pathname.startsWith("/plugins")) return "plugins";
if (pathname.startsWith("/settings")) return "settings";
if (pathname.startsWith("/login")) return "login";
return "other";
}

export function trackPage(pathname: string): void {
beacon({ kind: "page", name: pageNameForPath(pathname) });
}

export function trackAction(action: string): void {
beacon({ kind: "action", name: action });
}

// Derive an action key from a gateway-proxy action endpoint
// ("/api/job/action/kill" -> "job-kill"). Returns "" for non-action routes.
export function actionKeyForEndpoint(endpoint: string): string {
const m = endpoint.match(/\/api\/([a-z]+)\/action\/([a-z]+)/i);
return m ? `${m[1].toLowerCase()}-${m[2].toLowerCase()}` : "";
}

// Track an action by its endpoint (used by the shared action dispatcher).
export function trackActionEndpoint(endpoint: string): void {
const key = actionKeyForEndpoint(endpoint);
if (key) trackAction(key);
}

export function trackFacility(facility: string): void {
beacon({ kind: "facility", name: facility });
}

export function trackLogin(): void {
beacon({ kind: "login" });
}
39 changes: 39 additions & 0 deletions cueweb/components/ui/usage-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

/*
* Copyright Contributors to the OpenCue Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as React from "react";
import { usePathname } from "next/navigation";

import { trackPage } from "@/app/utils/usage_tracking";

// Mounted once from the root layout. Emits a usage page-view beacon whenever the
// route changes (deduped per pathname so a polling re-render doesn't inflate
// counts). Renders nothing.
export function UsageTracker() {
const pathname = usePathname();
const lastRef = React.useRef<string | null>(null);

React.useEffect(() => {
if (!pathname || pathname === lastRef.current) return;
lastRef.current = pathname;
// Don't count the login page as a module view; it gets its own login beacon.
if (!pathname.startsWith("/login")) trackPage(pathname);
}, [pathname]);

return null;
}
Loading
Loading