Skip to content

Commit 8a75d6a

Browse files
authored
Feature/api key usage analytics and coverage (#734)
* feat: add per-key API usage analytics Adds a new /api/api-keys/[id]/usage route, mock usage generator, client hook, and a /dashboard/api-keys/[id]/usage page rendering per-key request totals and a 14-day volume chart. Follows the existing production-safety rule: proxies to the configured mux-backend when NEXT_PUBLIC_API_URL is set, and returns 503 instead of mock data in production when no backend is configured. Closes the "Per-key usage analytics" roadmap item. * test: add extended coverage config for recovery, login, transactions-table, middleware vitest.config.ts's coverage.include omits /recovery, /login, /transactions-table, src/middleware.ts, and most src/app/api/** routes, even though tests already exist for all of them — so regressions there don't show up as a coverage drop. Adds a standalone vitest.coverage.full.config.ts (kept separate rather than editing the default config) that extends coverage to include those paths, plus docs/vitest-coverage-expansion.md documenting the gap and how to run it. --------- Co-authored-by: josunday002 <josunday002@users.noreply.github.com>
1 parent c36b050 commit 8a75d6a

11 files changed

Lines changed: 729 additions & 0 deletions

File tree

docs/api-key-usage-analytics.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Per-key API usage analytics
2+
3+
Implements the "Per-key usage analytics" roadmap item from `README.md`.
4+
5+
## What this adds
6+
7+
* `GET /api/api-keys/[id]/usage` — proxies to `${NEXT_PUBLIC_API_URL}/api-keys/:id/usage`
8+
(or its legacy aliases) when a backend is configured. Returns `503
9+
backend_unavailable` instead of mock data when no backend is configured in
10+
a production build (`NODE_ENV=production`), matching the "no silent mock
11+
success in production" rule documented in the root `README.md` and
12+
enforced by `isMockFallbackAllowed()` in `src/lib/api/config.ts`.
13+
* `src/mock-data/api-key-usage.ts` — dev/CI-only mock usage generator, used
14+
only as a fallback outside production when no backend is configured.
15+
* `src/hooks/useApiKeyUsage.ts` — client hook with the same
16+
loading/error/refetch shape as `useApiKeys`/`useWallets`.
17+
* `src/components/dashboard/ApiKeyUsageAnalytics.tsx` — renders total
18+
requests, requests in the last 24h, last-used timestamp, and a 14-day
19+
request volume chart for one API key.
20+
* `src/app/dashboard/api-keys/[id]/usage/page.tsx` — a new page at
21+
`/dashboard/api-keys/<id>/usage` that renders the analytics for a given
22+
key id (protected by the existing `/dashboard` auth middleware).
23+
24+
## Data source
25+
26+
Same rule as the rest of the app: when `NEXT_PUBLIC_API_URL` (or a legacy
27+
alias) is set, usage data comes from the real mux-backend
28+
(`GET {backend}/api-keys/:id/usage`) via server-side credentials
29+
(`MUX_API_KEY`/`MUX_API_SECRET`, attached server-side only — never exposed
30+
to the browser). No client-visible Mux credential is introduced by this
31+
feature, and no data is written to `localStorage`.
32+
33+
## Backend contract expected
34+
35+
`GET {backend}/api-keys/:id/usage` should return a JSON body shaped like
36+
`ApiKeyUsageSummary` in `src/mock-data/api-key-usage.ts`:
37+
38+
```ts
39+
{
40+
apiKeyId: string;
41+
totalRequests: number;
42+
requestsLast24h: number;
43+
lastUsedAt: string | null; // ISO timestamp
44+
dailyRequests: { date: string; requests: number }[]; // last 14 days, oldest first
45+
}
46+
```

docs/vitest-coverage-expansion.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Vitest coverage expansion (recovery, login, transactions-table, middleware)
2+
3+
## Gap
4+
5+
`vitest.config.ts`'s `coverage.include` list only covers a subset of the app:
6+
7+
```
8+
src/components/wallet/**
9+
src/components/analytics/**
10+
src/components/ui/**
11+
src/components/transactions/**
12+
src/lib/**
13+
src/utils/**
14+
src/hooks/**
15+
src/mock-data/**
16+
src/services/**
17+
src/app/**/wallets/**
18+
src/app/**/analytics/**
19+
```
20+
21+
It omits `/recovery`, `/login`, `/transactions-table`, `src/middleware.ts`,
22+
and most of `src/app/api/**`. Tests already exist for all of these areas —
23+
see:
24+
25+
* `src/app/recovery/__tests__/page.test.tsx`
26+
* `src/app/login/__tests__/*.test.tsx`
27+
* `src/app/transactions-table/page.test.tsx`
28+
* `src/test/middleware.test.ts`, `src/__tests__/middleware.test.ts`
29+
* `src/app/api/**/*.test.ts` (every route under `src/app/api` has a
30+
matching `route.test.ts`)
31+
32+
— they just aren't reflected in the coverage report, so a regression in
33+
any of these areas wouldn't show up as a coverage drop and could go
34+
unnoticed.
35+
36+
## Fix
37+
38+
`vitest.coverage.full.config.ts` (repo root) is an additive, standalone
39+
Vitest config — kept separate from `vitest.config.ts` rather than editing
40+
it in place — that extends the same coverage setup with:
41+
42+
```
43+
src/app/recovery/**
44+
src/app/login/**
45+
src/app/transactions-table/**
46+
src/middleware.ts
47+
src/app/api/**
48+
```
49+
50+
Run it with:
51+
52+
```bash
53+
pnpm exec vitest run --config vitest.coverage.full.config.ts --coverage
54+
```
55+
56+
This uses the same test files, setup, and alias resolution as
57+
`vitest.config.ts` — only the reported coverage surface is wider.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { GET } from "@/app/api/api-keys/[id]/usage/route";
3+
4+
function paramsFor(id: string) {
5+
return { params: { id } };
6+
}
7+
8+
describe("GET /api/api-keys/[id]/usage", () => {
9+
afterEach(() => {
10+
vi.unstubAllEnvs();
11+
});
12+
13+
it("returns deterministic mock usage analytics for a key", async () => {
14+
const res = await GET(
15+
new Request("http://localhost/api/api-keys/1/usage"),
16+
paramsFor("1"),
17+
);
18+
const json = await res.json();
19+
20+
expect(res.status).toBe(200);
21+
expect(json.data).toEqual(
22+
expect.objectContaining({
23+
apiKeyId: "1",
24+
totalRequests: expect.any(Number),
25+
requestsLast24h: expect.any(Number),
26+
dailyRequests: expect.any(Array),
27+
}),
28+
);
29+
expect(json.data.dailyRequests).toHaveLength(14);
30+
});
31+
32+
it("returns the same totals for the same key id (stable mock data)", async () => {
33+
const first = await GET(
34+
new Request("http://localhost/api/api-keys/2/usage"),
35+
paramsFor("2"),
36+
);
37+
const second = await GET(
38+
new Request("http://localhost/api/api-keys/2/usage"),
39+
paramsFor("2"),
40+
);
41+
42+
const firstJson = await first.json();
43+
const secondJson = await second.json();
44+
45+
expect(firstJson.data.dailyRequests).toEqual(secondJson.data.dailyRequests);
46+
});
47+
48+
it("rejects a blank id", async () => {
49+
const res = await GET(
50+
new Request("http://localhost/api/api-keys/%20/usage"),
51+
paramsFor(" "),
52+
);
53+
expect(res.status).toBe(400);
54+
});
55+
56+
it("returns 503 instead of mock data when no backend is configured in production", async () => {
57+
vi.stubEnv("NODE_ENV", "production");
58+
59+
const res = await GET(
60+
new Request("http://localhost/api/api-keys/1/usage"),
61+
paramsFor("1"),
62+
);
63+
const json = await res.json();
64+
65+
expect(res.status).toBe(503);
66+
expect(json.error).toBe("backend_unavailable");
67+
});
68+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
getApiBaseUrl,
4+
getUpstreamAuthHeaders,
5+
isMockFallbackAllowed,
6+
} from "@/lib/api/config";
7+
import { getApiKeyUsage } from "@/mock-data/api-key-usage";
8+
9+
/** 503 returned instead of mock data when no backend is configured in production. */
10+
function backendUnavailableResponse() {
11+
return NextResponse.json(
12+
{
13+
error: "backend_unavailable",
14+
message:
15+
"No API key usage backend is configured for this production deployment. Set NEXT_PUBLIC_API_URL.",
16+
},
17+
{ status: 503 },
18+
);
19+
}
20+
21+
type RouteContext = {
22+
params:
23+
| {
24+
id: string;
25+
}
26+
| Promise<{
27+
id: string;
28+
}>;
29+
};
30+
31+
function backendHeaders(): Record<string, string> {
32+
return {
33+
"content-type": "application/json",
34+
...getUpstreamAuthHeaders(),
35+
};
36+
}
37+
38+
/**
39+
* GET /api/api-keys/[id]/usage
40+
*
41+
* Per-key usage analytics (roadmap item: "Per-key usage analytics").
42+
* Proxies to the configured backend's real usage metrics
43+
* (NEXT_PUBLIC_API_URL or legacy aliases). Falls back to the local mock
44+
* generator only outside production and only when no backend is configured,
45+
* so local dev/CI keeps working without a running API server — mirrors the
46+
* production-safety rule used by `/api/api-keys` and `/api/wallets/[id]`.
47+
*/
48+
export async function GET(request: Request, { params }: RouteContext) {
49+
const { id } = await params;
50+
const apiKeyId = id.trim();
51+
52+
if (!apiKeyId) {
53+
return NextResponse.json({ error: "invalid_id" }, { status: 400 });
54+
}
55+
56+
const backendUrl = getApiBaseUrl();
57+
58+
if (backendUrl) {
59+
try {
60+
const upstream = await fetch(
61+
`${backendUrl}/api-keys/${encodeURIComponent(apiKeyId)}/usage`,
62+
{
63+
headers: backendHeaders(),
64+
cache: "no-store",
65+
},
66+
);
67+
const data = await upstream.json().catch(() => null);
68+
69+
if (!upstream.ok || data === null) {
70+
return NextResponse.json(
71+
{ error: "Unable to load API key usage from the backend" },
72+
{ status: upstream.status || 502 },
73+
);
74+
}
75+
76+
return NextResponse.json({ data });
77+
} catch {
78+
return NextResponse.json(
79+
{ error: "Unable to reach the API key usage backend" },
80+
{ status: 502 },
81+
);
82+
}
83+
}
84+
85+
if (!isMockFallbackAllowed()) {
86+
return backendUnavailableResponse();
87+
}
88+
89+
// --- Mock fallback (no NEXT_PUBLIC_API_URL set, non-production only) ---
90+
return NextResponse.json({ data: getApiKeyUsage(apiKeyId) });
91+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Link from "next/link";
2+
import { ApiKeyUsageAnalytics } from "@/components/dashboard/ApiKeyUsageAnalytics";
3+
import { PageHeader } from "@/components/ui/PageHeader";
4+
5+
export default async function ApiKeyUsagePage({
6+
params,
7+
}: {
8+
params: Promise<{ id: string }>;
9+
}) {
10+
const { id } = await params;
11+
12+
return (
13+
<div className="space-y-6">
14+
<PageHeader
15+
title="API Key Usage"
16+
description="Per-key request volume and activity"
17+
actions={
18+
<Link
19+
href="/dashboard/api-keys"
20+
className="text-sm font-medium text-blue-700 hover:underline dark:text-blue-400"
21+
>
22+
← Back to API keys
23+
</Link>
24+
}
25+
/>
26+
<ApiKeyUsageAnalytics apiKeyId={id} />
27+
</div>
28+
);
29+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { render, screen, waitFor } from "@testing-library/react";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
3+
import { ApiKeyUsageAnalytics } from "@/components/dashboard/ApiKeyUsageAnalytics";
4+
import type { ApiKeyUsageSummary } from "@/mock-data/api-key-usage";
5+
6+
const mockUsage: ApiKeyUsageSummary = {
7+
apiKeyId: "1",
8+
totalRequests: 1200,
9+
requestsLast24h: 42,
10+
lastUsedAt: "2024-01-20T00:00:00Z",
11+
dailyRequests: [
12+
{ date: "2024-01-19", requests: 10 },
13+
{ date: "2024-01-20", requests: 42 },
14+
],
15+
};
16+
17+
afterEach(() => {
18+
vi.restoreAllMocks();
19+
vi.unstubAllGlobals();
20+
});
21+
22+
describe("ApiKeyUsageAnalytics", () => {
23+
it("shows a loading state before data resolves", () => {
24+
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
25+
26+
render(<ApiKeyUsageAnalytics apiKeyId="1" />);
27+
expect(screen.getByRole("status", { name: /loading api key usage/i })).toBeInTheDocument();
28+
});
29+
30+
it("renders usage totals once loaded", async () => {
31+
vi.stubGlobal(
32+
"fetch",
33+
vi.fn().mockResolvedValue({
34+
ok: true,
35+
json: () => Promise.resolve({ data: mockUsage }),
36+
}),
37+
);
38+
39+
render(<ApiKeyUsageAnalytics apiKeyId="1" apiKeyName="Production" />);
40+
41+
await waitFor(() =>
42+
expect(screen.getByText(/usage analytics/i)).toBeInTheDocument(),
43+
);
44+
expect(screen.getByText("1,200")).toBeInTheDocument();
45+
expect(screen.getByText("42")).toBeInTheDocument();
46+
});
47+
48+
it("renders an error state with a retry action on failure", async () => {
49+
vi.stubGlobal(
50+
"fetch",
51+
vi.fn().mockResolvedValue({ ok: false, status: 502 }),
52+
);
53+
54+
render(<ApiKeyUsageAnalytics apiKeyId="1" />);
55+
56+
await waitFor(() =>
57+
expect(
58+
screen.getByText(/unable to load usage analytics/i),
59+
).toBeInTheDocument(),
60+
);
61+
});
62+
});

0 commit comments

Comments
 (0)