=> {
const url = encodeURIComponent(sourceUrl);
switch (extension) {
case "pmtiles":
- return {
- src: `https://pmtiles.io/#url=${url}&iframe=true`,
- style: { border: "none" },
- };
+ return `https://pmtiles.io/#url=${url}&iframe=true`;
case "parquet":
if (await isStacGeoParquet(sourceUrl)) {
- return {
- src: `https://developmentseed.org/stac-map?href=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://developmentseed.org/stac-map?href=${url}`;
}
- return {
- src: `https://source-cooperative.github.io/parquet-table/?iframe=true&url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/parquet-table/?iframe=true&url=${url}`;
case "csv":
case "tsv":
- return {
- src: `https://source-cooperative.github.io/csv-table/?iframe=true&url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/csv-table/?iframe=true&url=${url}`;
case "tif":
case "tiff":
- return {
- src: `https://source-cooperative.github.io/cog-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/cog-viewer/?url=${url}`;
case "avif":
case "bmp":
case "gif":
@@ -83,35 +71,20 @@ const getIframeAttributes = async (
case "png":
case "svg":
case "webp":
- return {
- src: `https://source-cooperative.github.io/image-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/image-viewer/?url=${url}`;
case "pdf":
- return {
- src: `https://source-cooperative.github.io/pdf-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/pdf-viewer/?url=${url}`;
case "glb":
case "gltf":
case "obj":
case "stl":
- return {
- src: `https://source-cooperative.github.io/model-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/model-viewer/?url=${url}`;
case "zip":
- return {
- src: `https://source-cooperative.github.io/zip-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/zip-viewer/?url=${url}`;
case "json":
case "jsonl":
case "ndjson":
- return {
- src: `https://source-cooperative.github.io/json-viewer/?url=${url}`,
- style: { border: "1px solid var(--gray-5)" },
- };
+ return `https://source-cooperative.github.io/json-viewer/?url=${url}`;
default:
return null;
}
@@ -125,43 +98,30 @@ export async function ObjectPreviewExternal(props: ObjectPreviewExternalProps) {
return null;
}
- const iframeProps = await getIframeAttributes(cloudUri, extension);
- if (!iframeProps) {
+ const src = await getIframeSrc(cloudUri, extension);
+ if (!src) {
return (
-
-
- No preview available for file type .{extension}.{" "}
-
- Open an issue
- {" "}
- if you would like support for this file type.
-
-
+
+ No preview available for file type .{extension}.{" "}
+
+ Open an issue
+ {" "}
+ if you would like support for this file type.
+
);
}
- const { src, style } = iframeProps;
return (
-
-
-
-
- Open in new tab
-
-
-
-
-
-
+
);
}
diff --git a/src/components/features/products/object-browser/ObjectPreviewInternal.tsx b/src/components/features/products/object-browser/ObjectPreviewInternal.tsx
index 0d69175e..e4f56c50 100644
--- a/src/components/features/products/object-browser/ObjectPreviewInternal.tsx
+++ b/src/components/features/products/object-browser/ObjectPreviewInternal.tsx
@@ -1,4 +1,3 @@
-import { Box } from "@radix-ui/themes";
import { LOGGER } from "@/lib";
import { getStorageClient } from "@/lib/clients/storage";
import { MarkdownViewer } from "@/components/features/markdown/MarkdownViewer";
@@ -63,14 +62,11 @@ export async function ObjectPreviewInternal(props: ObjectPreviewInternalProps) {
return null;
}
- const extension = getExtension(props.object_path);
- return (
-
- {extension === "md" || extension === "markdown" ? (
-
- ) : (
-
- )}
-
- );
+ switch (getExtension(props.object_path)) {
+ case "md":
+ case "markdown":
+ return ;
+ default:
+ return ;
+ }
}
diff --git a/src/lib/clients/analytics/index.test.ts b/src/lib/clients/analytics/index.test.ts
new file mode 100644
index 00000000..2b678a7a
--- /dev/null
+++ b/src/lib/clients/analytics/index.test.ts
@@ -0,0 +1,628 @@
+/**
+ * Tests for the Analytics Engine client: SQL construction (escaping, sampling
+ * weights, filters), response parsing/zero-filling, and top-N/"Other" math.
+ * The SQL API itself is mocked at the fetch layer.
+ */
+import {
+ getUsage,
+ getAdminBreakdown,
+ getProductBreakdowns,
+ USAGE_DAYS,
+} from "./index";
+import { CONFIG } from "@/lib/config";
+
+jest.mock("next/cache", () => ({
+ unstable_cache: (fn: unknown) => fn,
+}));
+
+jest.mock("@/lib/config", () => ({
+ CONFIG: {
+ analytics: {
+ accountId: "cf-account",
+ apiToken: "cf-token",
+ dataset: "test_dataset",
+ },
+ environment: { isDevelopment: false, isTest: true, stage: "test" },
+ // logging.ts reads this at module load
+ auth: { accessToken: "test-token" },
+ },
+}));
+
+const fetchMock = jest.fn();
+global.fetch = fetchMock as unknown as typeof fetch;
+
+function jsonResponse(rows: Record[]) {
+ return {
+ ok: true,
+ json: async () => ({ meta: [], data: rows, rows: rows.length }),
+ };
+}
+
+/** The SQL strings sent to the API, in call order. */
+const sentSql = () => fetchMock.mock.calls.map((call) => call[1].body as string);
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockResolvedValue(jsonResponse([]));
+});
+
+/** AE DateTime string for the start of the current UTC day. */
+function todayUtc(): string {
+ const d = new Date(new Date().setUTCHours(0, 0, 0, 0));
+ return d.toISOString().replace("T", " ").replace(".000Z", "");
+}
+
+/** YYYY-MM-DD for the UTC day n days ago. */
+const isoDaysAgo = (n: number) =>
+ new Date(new Date().setUTCHours(0, 0, 0, 0) - n * 86_400_000)
+ .toISOString()
+ .slice(0, 10);
+
+/** Inclusive single-day range covering today (hourly buckets). */
+const TODAY_RANGE = { from: isoDaysAgo(0), to: isoDaysAgo(0) };
+
+describe("getUsage", () => {
+ it("queries with sampling weights and served-bytes filters", async () => {
+ await getUsage("acct", "prod");
+
+ const [seriesSql, windowSql, ipsSql, registeredSql] = sentSql();
+ for (const sql of [seriesSql, windowSql, ipsSql, registeredSql]) {
+ // Float literals: AE 422s on Double-vs-Integer comparisons.
+ expect(sql).toContain("blob4 = 'GET' AND double2 IN (200.0, 206.0)");
+ expect(sql).toContain("blob1 = 'acct'");
+ expect(sql).toContain("blob2 = 'prod'");
+ // Day-aligned window: today (partial) + USAGE_DAYS-1 full UTC days,
+ // identical for series, totals, and breakdowns.
+ expect(sql).toContain(
+ `timestamp >= toStartOfDay(NOW() - INTERVAL '${USAGE_DAYS - 1}' DAY)`,
+ );
+ expect(sql).toContain("FROM test_dataset");
+ }
+ expect(seriesSql).toContain("toStartOfDay(timestamp)");
+ expect(seriesSql).toContain("SUM(_sample_interval * double1) AS bytes");
+ expect(seriesSql).toContain("SUM(_sample_interval) AS requests");
+ expect(windowSql).toContain("COUNT(DISTINCT blob6) AS countries");
+ expect(windowSql).toContain("sumIf(_sample_interval, blob5 = '') AS anon_requests");
+ expect(windowSql).not.toContain("GROUP BY");
+ expect(ipsSql).toContain("blob8 != ''");
+ expect(ipsSql).toContain("GROUP BY ip");
+ expect(registeredSql).toContain("COUNT(DISTINCT blob5) AS registered");
+ expect(registeredSql).toContain("blob5 != ''");
+
+ const [, options] = fetchMock.mock.calls[0];
+ expect(options.headers.Authorization).toBe("Bearer cf-token");
+ });
+
+ it("escapes quotes, backslashes, and control chars in values", async () => {
+ await getUsage("a'; DROP--", "pr\\od", "dir/we'ird\u0000.txt");
+
+ const sql = sentSql()[0];
+ expect(sql).toContain("blob1 = 'a\\'; DROP--'");
+ expect(sql).toContain("blob2 = 'pr\\\\od'");
+ expect(sql).toContain("blob3 = 'dir/we\\'ird.txt'");
+ });
+
+ it("truncates the object path filter to 256 bytes like the data proxy", async () => {
+ // 300 two-byte chars: proxy stores the first 256 bytes = 128 chars.
+ await getUsage("acct", "prod", "é".repeat(300));
+ expect(sentSql()[0]).toContain(`blob3 = '${"é".repeat(128)}'`);
+ });
+
+ it("zero-fills the day grid, coerces strings, and buckets user frequency", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ jsonResponse([
+ // UInt64 aggregates arrive as strings in the JSON format
+ { day: todayUtc(), bytes: 1024, requests: "7", countries: 2 },
+ ]),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse([{ countries: 2, anon_requests: "5" }]),
+ )
+ .mockResolvedValueOnce(
+ jsonResponse([
+ { ip: "h1", requests: 1 },
+ { ip: "h2", requests: "3" },
+ { ip: "h3", requests: 7 },
+ { ip: "h4", requests: 25 },
+ // sampled fraction rounds down to 0 → floored into the 1× bucket
+ { ip: "h5", requests: 0.4 },
+ ]),
+ )
+ .mockResolvedValueOnce(jsonResponse([{ registered: "2" }]));
+
+ const usage = await getUsage("acct", "prod");
+
+ expect(usage).not.toBeNull();
+ expect(usage!.days).toHaveLength(USAGE_DAYS);
+ const today = usage!.days[USAGE_DAYS - 1];
+ expect(today).toMatchObject({ bytes: 1024, requests: 7, countries: 2 });
+ // Every earlier day is zero-filled
+ expect(usage!.days[0]).toMatchObject({ bytes: 0, requests: 0 });
+ expect(usage!.totals).toEqual({ bytes: 1024, requests: 7, countries: 2 });
+ // Quasi-log bins: the 0.4 sampled fraction floors into "1" alongside
+ // the exact-1 IP; 3 → "3–5", 7 → "6–10", 25 → "11–25"; the rest zero.
+ expect(usage!.users).toEqual({
+ uniqueIps: 5,
+ registered: 2,
+ anonRequests: 5,
+ distribution: [
+ { label: "1", ips: 2 },
+ { label: "2", ips: 0 },
+ { label: "3–5", ips: 1 },
+ { label: "6–10", ips: 1 },
+ { label: "11–25", ips: 1 },
+ { label: "26–50", ips: 0 },
+ { label: "51–100", ips: 0 },
+ { label: "101–250", ips: 0 },
+ { label: "251–1K", ips: 0 },
+ { label: "1K+", ips: 0 },
+ ],
+ });
+ });
+
+ it("applies the requested window to the queries and the grid", async () => {
+ const usage = await getUsage("acct", "prod", undefined, 7);
+ expect(sentSql()[0]).toContain("toStartOfDay(NOW() - INTERVAL '6' DAY)");
+ expect(usage!.days).toHaveLength(7);
+ });
+
+ it("returns null when analytics is not configured", async () => {
+ const token = CONFIG.analytics.apiToken;
+ (CONFIG.analytics as { apiToken: string }).apiToken = "";
+ try {
+ expect(await getUsage("acct", "prod")).toBeNull();
+ expect(fetchMock).not.toHaveBeenCalled();
+ } finally {
+ (CONFIG.analytics as { apiToken: string }).apiToken = token;
+ }
+ });
+
+ it("returns null when the query fails", async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 500,
+ text: async () => "boom",
+ });
+ expect(await getUsage("acct", "prod")).toBeNull();
+ });
+});
+
+describe("getProductBreakdowns", () => {
+ it("ranks countries with an others aggregate and lists top files", async () => {
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY country")) {
+ return jsonResponse([
+ { country: "US", requests: 100 },
+ { country: "DE", requests: 50 },
+ { country: "BR", requests: 40 },
+ { country: "GB", requests: 30 },
+ { country: "IN", requests: "20" },
+ { country: "FR", requests: 10 },
+ { country: "", requests: 5 },
+ ]);
+ }
+ return jsonResponse([
+ { file: "a.tif", requests: 60, bytes: 1000 },
+ { file: "b.json", requests: "40", bytes: "500" },
+ ]);
+ });
+
+ const breakdowns = await getProductBreakdowns("acct", "prod", 30);
+
+ expect(breakdowns!.countries).toHaveLength(5);
+ expect(breakdowns!.countries[0]).toEqual({
+ code: "US",
+ name: "United States",
+ requests: 100,
+ });
+ expect(breakdowns!.otherCountries).toEqual({ count: 2, requests: 15 });
+ expect(breakdowns!.files).toEqual([
+ { path: "a.tif", requests: 60, bytes: 1000 },
+ { path: "b.json", requests: 40, bytes: 500 },
+ ]);
+
+ const fileSql = sentSql().find((sql) => sql.includes("GROUP BY file"));
+ expect(fileSql).toContain("ORDER BY requests DESC");
+ expect(fileSql).toContain("LIMIT 10");
+ expect(fileSql).toContain("blob1 = 'acct'");
+ // Keyless product GETs (blob3 = '') are probes/listings, not files
+ expect(fileSql).toContain("blob3 != ''");
+ });
+
+ it("returns no others aggregate when few countries", async () => {
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ return jsonResponse(
+ init.body.includes("GROUP BY country")
+ ? [{ country: "US", requests: 10 }]
+ : [],
+ );
+ });
+ const breakdowns = await getProductBreakdowns("acct", "prod", 7);
+ expect(breakdowns!.otherCountries).toBeNull();
+ expect(breakdowns!.files).toEqual([]);
+ });
+
+ it("returns null when the query fails", async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 500,
+ text: async () => "boom",
+ });
+ expect(await getProductBreakdowns("acct", "prod", 30)).toBeNull();
+ });
+});
+
+describe("getAdminBreakdown", () => {
+ it("returns a single 'All traffic' series when not grouping", async () => {
+ const bucket = todayUtc();
+ fetchMock.mockResolvedValue(
+ jsonResponse([{ bucket, bytes: 500, requests: "5" }]),
+ );
+
+ const breakdown = await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [] });
+
+ // Bucket totals plus the headline distinct-counts query
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ const sql = sentSql()[0];
+ // Single-day range → hourly buckets from today's UTC midnight, no upper bound
+ expect(sql).toContain("toStartOfInterval(timestamp, INTERVAL '1' HOUR)");
+ expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '0' DAY)");
+ expect(sql).not.toContain("timestamp <");
+ expect(sentSql()[1]).toContain("COUNT(DISTINCT blob6)");
+ expect(sentSql()[1]).toContain("COUNT(DISTINCT blob8)");
+
+ expect(breakdown!.range).toEqual(TODAY_RANGE);
+ expect(breakdown!.series).toEqual(["All traffic"]);
+ expect(breakdown!.totals).toEqual({
+ bytes: 500,
+ requests: 5,
+ uniqueIps: 0,
+ countries: 0,
+ });
+ // Hourly buckets: midnight through the in-progress hour
+ expect(breakdown!.buckets.length).toBeGreaterThanOrEqual(1);
+ expect(breakdown!.buckets.length).toBeLessThanOrEqual(25);
+ const filled = breakdown!.points.filter((p) => p["All traffic"]);
+ expect(filled).toEqual([{ "All traffic": { bytes: 500, requests: 5 } }]);
+ });
+
+ it("applies dimension filters over a week range", async () => {
+ await getAdminBreakdown({
+ from: isoDaysAgo(6),
+ to: isoDaysAgo(0),
+ groupBy: [],
+ filters: {
+ account: "ft'w",
+ product: "global",
+ country: "us",
+ client: "ab%_c",
+ },
+ });
+ const sql = sentSql()[0];
+ expect(sql).toContain("blob1 = 'ft\\'w'");
+ expect(sql).toContain("blob2 = 'global'");
+ // Country codes are stored uppercase
+ expect(sql).toContain("blob6 = 'US'");
+ // IP hashes prefix-match (the UI shows 12-char prefixes), with LIKE
+ // wildcards in the value escaped
+ expect(sql).toContain("blob8 LIKE 'ab\\\\%\\\\_c%'");
+ expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '6' DAY)");
+ expect(sql).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)");
+ });
+
+ it("honors a whitelisted sum interval and escalates unreadable ones", async () => {
+ await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [], bucketMinutes: 360 });
+ expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)");
+
+ fetchMock.mockClear();
+ // Hourly over ~92 days would be ~2,200 bars — escalates until drawable.
+ await getAdminBreakdown({
+ from: isoDaysAgo(91),
+ to: isoDaysAgo(0),
+ groupBy: [],
+ bucketMinutes: 60,
+ });
+ expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '6' HOUR)");
+
+ fetchMock.mockClear();
+ // Minute buckets over a full day would be 1,440 bars — escalates to the
+ // 15-minute ladder rung, via AE's dedicated function.
+ const escalated = await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: [],
+ bucketMinutes: 1,
+ });
+ expect(sentSql()[0]).toContain("toStartOfFifteenMinutes(timestamp)");
+ expect(escalated!.bucketMinutes).toBe(15);
+
+ fetchMock.mockClear();
+ // Non-whitelisted values fall back to auto (hourly for a single day).
+ await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [], bucketMinutes: 5 });
+ expect(sentSql()[0]).toContain("toStartOfInterval(timestamp, INTERVAL '1' HOUR)");
+ });
+
+ it("collapses midnight-aligned datetime bounds to the day-grained path", async () => {
+ // The filter form always submits datetime-local values; a pair landing
+ // on midnights is a plain day range and keeps the proven SQL forms.
+ const breakdown = await getAdminBreakdown({
+ from: `${isoDaysAgo(7)}T00:00`,
+ to: `${isoDaysAgo(0)}T00:00`, // exclusive → through yesterday
+ groupBy: [],
+ });
+ const sql = sentSql()[0];
+ expect(sql).toContain("toStartOfDay(NOW() - INTERVAL '7' DAY)");
+ expect(sql).not.toContain("toDateTime");
+ expect(breakdown!.range).toEqual({
+ from: isoDaysAgo(7),
+ to: isoDaysAgo(1),
+ });
+ });
+
+ it("bounds sub-day drill ranges with toDateTime and buckets by minute", async () => {
+ const day = isoDaysAgo(1);
+ const breakdown = await getAdminBreakdown({
+ from: `${day}T13:00`,
+ to: `${day}T14:00`,
+ groupBy: [],
+ bucketMinutes: 1,
+ });
+
+ const sql = sentSql()[0];
+ expect(sql).toContain(`timestamp >= toDateTime('${day} 13:00:00')`);
+ expect(sql).toContain(`timestamp < toDateTime('${day} 14:00:00')`);
+ expect(sql).toContain("toStartOfMinute(timestamp) AS bucket");
+ expect(breakdown!.bucketMinutes).toBe(1);
+ expect(breakdown!.range).toEqual({
+ from: `${day}T13:00`,
+ to: `${day}T14:00`,
+ });
+ // Zero-filled minute grid over the drilled hour
+ expect(breakdown!.buckets).toHaveLength(60);
+ expect(breakdown!.buckets[0]).toBe(`${day}T13:00:00.000Z`);
+ expect(breakdown!.buckets[59]).toBe(`${day}T13:59:00.000Z`);
+ });
+
+ it("folds daily SQL buckets into weekly buckets aligned to the range start", async () => {
+ // AE degrades >24h toStartOfInterval to daily buckets, so week buckets
+ // are assembled here from daily rows.
+ const aeDay = (n: number) => `${isoDaysAgo(n)} 00:00:00`;
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY bucket, blob1, blob2")) {
+ return jsonResponse([
+ { bucket: aeDay(13), blob1: "a1", blob2: "p1", bytes: 100, requests: 1 },
+ { bucket: aeDay(12), blob1: "a1", blob2: "p1", bytes: 50, requests: 1 },
+ ]);
+ }
+ if (sql.includes("GROUP BY bucket")) {
+ return jsonResponse([
+ { bucket: aeDay(13), bytes: 120, requests: 2 },
+ { bucket: aeDay(12), bytes: 60, requests: 1 },
+ { bucket: aeDay(5), bytes: 30, requests: 1 },
+ ]);
+ }
+ if (sql.includes("COUNT(DISTINCT")) {
+ // '' present among the hashes (no_ip > 0) → one distinct value dropped
+ return jsonResponse([{ countries: 3, ips: 5, no_ip: 10 }]);
+ }
+ return jsonResponse([{ blob1: "a1", blob2: "p1", bytes: 150, requests: 2 }]);
+ });
+
+ const breakdown = await getAdminBreakdown({
+ from: isoDaysAgo(13),
+ to: isoDaysAgo(0),
+ groupBy: ["product"],
+ bucketMinutes: 10080,
+ });
+
+ expect(sentSql()[0]).toContain("toStartOfDay(timestamp) AS bucket");
+ expect(sentSql()[0]).not.toContain("toStartOfInterval");
+ expect(breakdown!.bucketMinutes).toBe(10080);
+ // Two week buckets starting at `from`, not at epoch-aligned Thursdays
+ expect(breakdown!.buckets).toEqual([
+ `${isoDaysAgo(13)}T00:00:00.000Z`,
+ `${isoDaysAgo(6)}T00:00:00.000Z`,
+ ]);
+ // Days 13+12 accumulate into week one; Other = bucket total - charted
+ expect(breakdown!.points).toEqual([
+ {
+ "a1/p1": { bytes: 150, requests: 2 },
+ Other: { bytes: 30, requests: 1 },
+ },
+ { Other: { bytes: 30, requests: 1 } },
+ ]);
+ expect(breakdown!.totals).toEqual({
+ bytes: 210,
+ requests: 4,
+ uniqueIps: 4,
+ countries: 3,
+ });
+ });
+
+ it("swaps reversed bounds and bounds ranges that end before today", async () => {
+ const breakdown = await getAdminBreakdown({
+ from: isoDaysAgo(3),
+ to: isoDaysAgo(10),
+ groupBy: [],
+ });
+ const sql = sentSql()[0];
+ expect(sql).toContain("timestamp >= toStartOfDay(NOW() - INTERVAL '10' DAY)");
+ // Exclusive upper bound: the start of the day after `to` (3 days ago)
+ expect(sql).toContain("timestamp < toStartOfDay(NOW() - INTERVAL '2' DAY)");
+ // 8-day range → daily buckets, via the proven toStartOfDay form
+ expect(sql).toContain("toStartOfDay(timestamp) AS bucket");
+ expect(breakdown!.range).toEqual({ from: isoDaysAgo(10), to: isoDaysAgo(3) });
+ });
+
+ it("ranks groups, charts the top slice, and derives Other from totals", async () => {
+ const bucket = todayUtc();
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY bucket, blob1, blob2")) {
+ // Timeseries for the charted groups only
+ return jsonResponse([
+ { bucket, blob1: "a1", blob2: "p1", bytes: 600, requests: 6 },
+ { bucket, blob1: "a2", blob2: "p2", bytes: 300, requests: 3 },
+ ]);
+ }
+ if (sql.includes("GROUP BY bucket")) {
+ // Overall per-bucket totals (includes long-tail traffic)
+ return jsonResponse([{ bucket, bytes: 1000, requests: 10 }]);
+ }
+ if (sql.includes("COUNT(DISTINCT")) {
+ // No '' hash seen (no_ip = 0) → nothing subtracted
+ return jsonResponse([{ countries: 7, ips: 4, no_ip: 0 }]);
+ }
+ // Ranked totals per group
+ return jsonResponse([
+ { blob1: "a1", blob2: "p1", bytes: 600, requests: 6 },
+ { blob1: "a2", blob2: "p2", bytes: 300, requests: 3 },
+ ]);
+ });
+
+ const breakdown = await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: ["product"],
+ });
+
+ // The timeseries query is filtered to the charted groups
+ const seriesSql = sentSql().find((sql) =>
+ sql.includes("GROUP BY bucket, blob1, blob2"),
+ );
+ expect(seriesSql).toContain("(blob1 = 'a1' AND blob2 = 'p1')");
+ expect(seriesSql).toContain("(blob1 = 'a2' AND blob2 = 'p2')");
+
+ // Ranking follows the metric — requests by default
+ const rankSql = sentSql().find(
+ (sql) => sql.includes("GROUP BY blob1, blob2") && sql.includes("LIMIT"),
+ );
+ expect(rankSql).toContain("ORDER BY requests DESC");
+
+ expect(breakdown!.series).toEqual(["a1/p1", "a2/p2", "Other"]);
+ expect(breakdown!.totals).toEqual({
+ bytes: 1000,
+ requests: 10,
+ uniqueIps: 4,
+ countries: 7,
+ });
+
+ const point = breakdown!.points.find((p) => p["a1/p1"]);
+ expect(point).toEqual({
+ "a1/p1": { bytes: 600, requests: 6 },
+ "a2/p2": { bytes: 300, requests: 3 },
+ Other: { bytes: 100, requests: 1 },
+ });
+
+ // Table rows: ranked groups plus the beyond-top-N remainder
+ expect(breakdown!.groups).toEqual([
+ { key: "a1/p1", bytes: 600, requests: 6 },
+ { key: "a2/p2", bytes: 300, requests: 3 },
+ { key: "Other", bytes: 100, requests: 1 },
+ ]);
+ });
+
+ it("ranks by bytes when that metric is selected", async () => {
+ await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: ["account"],
+ metric: "bytes",
+ });
+ const rankSql = sentSql().find(
+ (sql) => sql.includes("GROUP BY blob1") && sql.includes("LIMIT"),
+ );
+ expect(rankSql).toContain("ORDER BY bytes DESC");
+ });
+
+ it("keeps a requests-only Other remainder visible", async () => {
+ const bucket = todayUtc();
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY bucket, blob1")) {
+ return jsonResponse([{ bucket, blob1: "a1", bytes: 500, requests: 5 }]);
+ }
+ if (sql.includes("GROUP BY bucket")) {
+ // Long tail served zero-byte responses: bytes covered, requests not.
+ return jsonResponse([{ bucket, bytes: 500, requests: 9 }]);
+ }
+ return jsonResponse([{ blob1: "a1", bytes: 500, requests: 5 }]);
+ });
+
+ const breakdown = await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: ["account"],
+ });
+
+ expect(breakdown!.series).toEqual(["a1", "Other"]);
+ const point = breakdown!.points.find((p) => p.Other);
+ expect(point!.Other).toEqual({ bytes: 0, requests: 4 });
+ expect(breakdown!.groups).toEqual([
+ { key: "a1", bytes: 500, requests: 5 },
+ { key: "Other", bytes: 0, requests: 4 },
+ ]);
+ });
+
+ it("composes keys across multiple group-bys", async () => {
+ const bucket = todayUtc();
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY bucket, blob1, blob6")) {
+ return jsonResponse([
+ { bucket, blob1: "a1", blob6: "US", bytes: 100, requests: 1 },
+ ]);
+ }
+ if (sql.includes("GROUP BY bucket")) {
+ return jsonResponse([{ bucket, bytes: 100, requests: 1 }]);
+ }
+ return jsonResponse([{ blob1: "a1", blob6: "US", bytes: 100, requests: 1 }]);
+ });
+
+ const breakdown = await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: ["account", "country"],
+ });
+ expect(breakdown!.series).toEqual(["a1 · United States (US)"]);
+ });
+
+ it("labels empty dimension values as unknown", async () => {
+ const bucket = todayUtc();
+ fetchMock.mockImplementation(async (_url: string, init: { body: string }) => {
+ const sql = init.body;
+ if (sql.includes("GROUP BY bucket, blob6")) {
+ return jsonResponse([{ bucket, blob6: "", bytes: 50, requests: 1 }]);
+ }
+ if (sql.includes("GROUP BY bucket")) {
+ return jsonResponse([{ bucket, bytes: 50, requests: 1 }]);
+ }
+ return jsonResponse([{ blob6: "", bytes: 50, requests: 1 }]);
+ });
+
+ const breakdown = await getAdminBreakdown({
+ ...TODAY_RANGE,
+ groupBy: ["country"],
+ });
+ expect(breakdown!.series).toEqual(["(unknown)"]);
+ });
+
+ it("returns null when analytics is not configured", async () => {
+ const token = CONFIG.analytics.apiToken;
+ (CONFIG.analytics as { apiToken: string }).apiToken = "";
+ try {
+ expect(await getAdminBreakdown({ ...TODAY_RANGE, groupBy: [] })).toBeNull();
+ } finally {
+ (CONFIG.analytics as { apiToken: string }).apiToken = token;
+ }
+ });
+
+ it("propagates query failures", async () => {
+ fetchMock.mockResolvedValue({
+ ok: false,
+ status: 403,
+ text: async () => "denied",
+ });
+ await expect(
+ getAdminBreakdown({ ...TODAY_RANGE, groupBy: ["account"] }),
+ ).rejects.toThrow("Analytics Engine query failed (403)");
+ });
+});
diff --git a/src/lib/clients/analytics/index.ts b/src/lib/clients/analytics/index.ts
new file mode 100644
index 00000000..8690f4be
--- /dev/null
+++ b/src/lib/clients/analytics/index.ts
@@ -0,0 +1,782 @@
+/**
+ * Cloudflare Analytics Engine client for data-proxy request analytics.
+ *
+ * The data proxy (data.source.coop) writes one event per request with this
+ * schema (see its src/analytics.rs):
+ *
+ * blob1: account_id blob6: country
+ * blob2: product_id blob7: content_type
+ * blob3: file_path blob8: client_ip_hash (empty if IP unknown)
+ * blob4: method blob9: range header
+ * blob5: user_id double1: bytes_sent
+ * double2: status_code
+ * double3: duration_ms
+ *
+ * Analytics Engine samples writes, so every count/sum must be weighted by
+ * `_sample_interval`; COUNT(DISTINCT …) is the best available estimate for
+ * uniques. It has no parameterized queries — every interpolated string goes
+ * through sqlQuote(), and windows/buckets/columns come from whitelists only.
+ *
+ * Server-only: queries run over the SQL API with an account-level token.
+ */
+import "server-only";
+import { unstable_cache } from "next/cache";
+import { CONFIG } from "@/lib/config";
+import { LOGGER } from "@/lib/logging";
+import { withTimeout } from "@/lib/with-timeout";
+
+// Whole weeks only: a 30-day window sometimes holds 8 weekend days and
+// sometimes 10, aliasing day-of-week patterns into period comparisons.
+export const USAGE_DAYS = 28;
+
+/** Windows offered on the product analytics page (bounded by AE retention). */
+export const USAGE_WINDOWS = [7, 28, 91] as const;
+export type UsageWindow = (typeof USAGE_WINDOWS)[number];
+
+export interface UsageTotals {
+ bytes: number;
+ requests: number; // downloads: successful GETs, sample-weighted
+ countries: number;
+}
+
+export interface UsagePoint extends UsageTotals {
+ /** ISO timestamp of the UTC day start */
+ date: string;
+}
+
+/**
+ * Downloads-per-IP histogram bins. Quasi-log edges: the distribution is
+ * heavy-tailed (a 1× spike and a tail spanning orders of magnitude), so
+ * linear bins waste the axis on empty slots and pool the tail into one
+ * lump. Last bin is open-ended.
+ */
+export const FREQUENCY_BINS = [
+ { label: "1", max: 1 },
+ { label: "2", max: 2 },
+ { label: "3–5", max: 5 },
+ { label: "6–10", max: 10 },
+ { label: "11–25", max: 25 },
+ { label: "26–50", max: 50 },
+ { label: "51–100", max: 100 },
+ { label: "101–250", max: 250 },
+ { label: "251–1K", max: 1000 },
+ { label: "1K+", max: Infinity },
+] as const;
+
+export interface UsageUsers {
+ /** Distinct client IP hashes (blob8) that downloaded in the window */
+ uniqueIps: number;
+ /** Distinct signed-in users (blob5) that downloaded in the window */
+ registered: number;
+ /** Sample-weighted requests with no signed-in user */
+ anonRequests: number;
+ /** Unique IPs per FREQUENCY_BINS downloads-per-IP bin, zero-filled */
+ distribution: { label: string; ips: number }[];
+}
+
+export interface Usage {
+ /** One point per UTC day, oldest first, zero-filled — always USAGE_DAYS long */
+ days: UsagePoint[];
+ totals: UsageTotals;
+ users: UsageUsers;
+}
+
+const DAY_MS = 86_400_000;
+/** Analytics Engine retains roughly three months of events. */
+export const RETENTION_DAYS = 92;
+
+export const ADMIN_DIMENSIONS = {
+ account: { label: "Account", columns: ["blob1"] },
+ product: { label: "Product", columns: ["blob1", "blob2"] },
+ country: { label: "Country", columns: ["blob6"] },
+ // "IP hash", not "Client" — client reads as the client_id/User-Agent header
+ client: { label: "IP hash", columns: ["blob8"] },
+} as const;
+export type AdminDimension = keyof typeof ADMIN_DIMENSIONS;
+
+/**
+ * How many series the stacked chart shows before folding into "Other" —
+ * matches the fixed categorical palette size (hues are never cycled).
+ */
+const CHART_SERIES_LIMIT = 6;
+/** How many rows the ranked totals table shows. */
+const TABLE_GROUP_LIMIT = 25;
+export const OTHER_KEY = "Other";
+
+/**
+ * Ceiling on chart bars: beyond this the SVG gets unwieldy (stacked rects ×
+ * series). Intervals that would exceed it are escalated to the next size.
+ */
+export const MAX_CHART_BUCKETS = 400;
+
+/** Sum intervals selectable in the admin explorer, in minutes. */
+export const BUCKET_INTERVALS = [
+ { minutes: 1, label: "Minute" },
+ { minutes: 60, label: "Hourly" },
+ { minutes: 360, label: "6-hour" },
+ { minutes: 1440, label: "Daily" },
+ { minutes: 10080, label: "Weekly" },
+] as const;
+
+export interface AdminQuery {
+ /**
+ * UTC day "YYYY-MM-DD" (inclusive) or UTC instant "YYYY-MM-DDTHH:MM"
+ * (as `to`, the exclusive end). Invalid/out-of-range values are clamped.
+ */
+ from: string;
+ to: string;
+ /** Sum interval override (a BUCKET_INTERVALS minutes value); omit for auto */
+ bucketMinutes?: number;
+ /** Ranking metric: orders the groups table and picks the charted slice */
+ metric?: "bytes" | "requests";
+ groupBy: AdminDimension[];
+ /** Per-dimension value filters (client = IP-hash prefix match) */
+ filters?: Partial>;
+}
+
+export interface AdminBreakdown {
+ /** ISO bucket-start timestamps, oldest first, zero-filled */
+ buckets: string[];
+ bucketMinutes: number;
+ /** Chart series keys ranked by bytes desc; last may be OTHER_KEY */
+ series: string[];
+ /** Per bucket, bytes/requests per series key (absent key = zero) */
+ points: Record[];
+ /** Ranked totals for the table (top groups, then optionally OTHER_KEY) */
+ groups: { key: string; bytes: number; requests: number }[];
+ /** Whole-range headline stats; the uniques are sampling estimates */
+ totals: {
+ bytes: number;
+ requests: number;
+ uniqueIps: number;
+ countries: number;
+ };
+ /**
+ * The resolved (validated/clamped) range actually queried, echoing the
+ * param grammar: day-grained "YYYY-MM-DD" pairs are inclusive, while
+ * time-grained "YYYY-MM-DDTHH:MM" pairs carry an exclusive `to`.
+ */
+ range: { from: string; to: string };
+ /** The SQL statements executed, for the admin "show SQL" viewer */
+ queries: string[];
+}
+
+/**
+ * Only count responses that actually served product bytes: HEAD responses
+ * carry a Content-Length that the proxy logs as bytes_sent without a body,
+ * and listing requests have no product segment (blob2 = '').
+ *
+ * Float literals are load-bearing: AE's type checker rejects comparing the
+ * Double column double2 against Integer literals (422 "IN expression types
+ * must be consistent").
+ */
+const SERVED_FILTER =
+ "blob4 = 'GET' AND double2 IN (200.0, 206.0) AND blob2 != ''";
+
+export function isAnalyticsConfigured(): boolean {
+ const { accountId, apiToken, dataset } = CONFIG.analytics;
+ return Boolean(accountId && apiToken && dataset);
+}
+
+/** Quote a string for an Analytics Engine SQL literal (no parameterized queries exist). */
+function sqlQuote(value: string): string {
+ const cleaned = value
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\x00-\x1f]/g, "")
+ .replace(/\\/g, "\\\\")
+ .replace(/'/g, "\\'");
+ return `'${cleaned}'`;
+}
+
+/**
+ * Truncate to `maxBytes` UTF-8 bytes on a char boundary — mirrors the data
+ * proxy's truncation of blob3, so filters match what was actually stored.
+ */
+function truncateToByteLimit(s: string, maxBytes: number): string {
+ const bytes = new TextEncoder().encode(s);
+ if (bytes.length <= maxBytes) return s;
+ return new TextDecoder()
+ .decode(bytes.slice(0, maxBytes))
+ .replace(/�+$/, "");
+}
+
+const num = (v: unknown): number => {
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) ? n : 0;
+};
+
+const str = (v: unknown): string => (typeof v === "string" ? v : "");
+
+/** Parse an Analytics Engine DateTime ("2026-07-06 12:00:00") as UTC ISO. */
+function parseDateTime(v: unknown): string {
+ return new Date(`${str(v).replace(" ", "T")}Z`).toISOString();
+}
+
+type Row = Record;
+
+async function runQuery(sql: string): Promise {
+ const { accountId, apiToken } = CONFIG.analytics;
+ // withTimeout: a hung Analytics Engine API must not hold the page's
+ // Suspense boundary (and the serverless invocation) open indefinitely.
+ const res = await withTimeout(
+ fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${apiToken}` },
+ body: sql,
+ // The Analytics Engine response is cached by the unstable_cache
+ // wrappers below, not by the fetch data cache.
+ cache: "no-store",
+ },
+ ),
+ 15_000,
+ "Analytics Engine query timed out",
+ );
+ if (!res.ok) {
+ throw new Error(
+ `Analytics Engine query failed (${res.status}): ${(await res.text()).slice(0, 500)}`,
+ );
+ }
+ const body = (await res.json()) as { data?: Row[] };
+ return body.data ?? [];
+}
+
+// unstable_cache includes the function arguments (the SQL string) in its key,
+// so each (account, product, object, window) caches independently.
+//
+// Product/object stats are non-dynamic — 30-day aggregates barely move, so
+// they rerun at most every 4 hours. The admin explorer is an interactive
+// surface with a 24h window, so it stays comparatively fresh.
+const usageQuery = unstable_cache(runQuery, ["analytics-usage"], {
+ revalidate: 4 * 3600,
+});
+const adminQuery = unstable_cache(runQuery, ["analytics-admin"], {
+ revalidate: 900,
+});
+
+const USAGE_AGGREGATES = `
+ SUM(_sample_interval * double1) AS bytes,
+ SUM(_sample_interval) AS requests,
+ COUNT(DISTINCT blob6) AS countries`;
+
+/**
+ * Shared FROM/WHERE for the usage queries. The window is day-aligned in SQL
+ * — today (partial) plus days-1 full UTC days — so the day grid, headline
+ * totals, and the country/file breakdowns all cover the identical span. (A
+ * rolling NOW()-Nd window would include the tail of an extra calendar day
+ * that the day grid drops, making breakdown sums exceed the headline.)
+ */
+function usageFrom(
+ accountId: string,
+ productId: string,
+ objectPath: string | undefined,
+ days: UsageWindow,
+): string {
+ const filters = [
+ SERVED_FILTER,
+ `timestamp >= toStartOfDay(NOW() - INTERVAL '${days - 1}' DAY)`,
+ `blob1 = ${sqlQuote(accountId)}`,
+ `blob2 = ${sqlQuote(productId)}`,
+ ];
+ if (objectPath !== undefined) {
+ filters.push(`blob3 = ${sqlQuote(truncateToByteLimit(objectPath, 256))}`);
+ }
+ return `FROM ${CONFIG.analytics.dataset} WHERE ${filters.join(" AND ")}`;
+}
+
+/**
+ * Recent usage (USAGE_DAYS) for a product, or a single object when `objectPath` is given.
+ * Returns null when analytics is unconfigured or the query fails — callers
+ * render nothing rather than breaking the page.
+ */
+export async function getUsage(
+ accountId: string,
+ productId: string,
+ objectPath?: string,
+ days: UsageWindow = USAGE_DAYS,
+): Promise {
+ if (!isAnalyticsConfigured()) return null;
+
+ const from = usageFrom(accountId, productId, objectPath, days);
+
+ try {
+ const [seriesRows, windowRows, ipRows, registeredRows] = await Promise.all([
+ usageQuery(
+ `SELECT toStartOfDay(timestamp) AS day, ${USAGE_AGGREGATES} ${from} GROUP BY day ORDER BY day`,
+ ),
+ // Separate query: window-wide DISTINCT can't be summed from days.
+ usageQuery(
+ `SELECT COUNT(DISTINCT blob6) AS countries, sumIf(_sample_interval, blob5 = '') AS anon_requests ${from}`,
+ ),
+ // Sample-weighted request count per unique client IP hash, for the
+ // download-frequency histogram (blob8 is empty when the IP is unknown).
+ // ponytail: capped at AE's ~10k response rows — a product with more
+ // unique IPs in the window gets an (arbitrary, roughly unbiased)
+ // sample; the histogram is labeled an estimate anyway.
+ usageQuery(
+ `SELECT blob8 AS ip, SUM(_sample_interval) AS requests ${from} AND blob8 != '' GROUP BY ip`,
+ ),
+ usageQuery(
+ `SELECT COUNT(DISTINCT blob5) AS registered ${from} AND blob5 != ''`,
+ ),
+ ]);
+
+ const byDay = new Map(
+ seriesRows.map((row) => [parseDateTime(row.day), row]),
+ );
+ const todayStart = new Date().setUTCHours(0, 0, 0, 0);
+ const points: UsagePoint[] = Array.from({ length: days }, (_, i) => {
+ const date = new Date(
+ todayStart - (days - 1 - i) * 86_400_000,
+ ).toISOString();
+ const row = byDay.get(date) ?? {};
+ return { date, ...parseUsageAggregates(row) };
+ });
+
+ // Additive totals come from the grid days, so bars and headline always
+ // agree; the extra (off-grid) partial day the SQL window touches is
+ // dropped with them. The uniques (`countries` here, the users counts
+ // below) can't be re-summed from days, so they keep that sliver —
+ // they're estimates over a marginally wider span.
+ const totals = points.reduce(
+ (acc, day) => ({
+ bytes: acc.bytes + day.bytes,
+ requests: acc.requests + day.requests,
+ countries: acc.countries,
+ }),
+ { bytes: 0, requests: 0, countries: num(windowRows[0]?.countries) },
+ );
+
+ const distribution = FREQUENCY_BINS.map(({ label }) => ({
+ label,
+ ips: 0,
+ }));
+ for (const row of ipRows) {
+ // Sampling makes per-IP counts fractional estimates; round, floor 1.
+ const downloads = Math.max(1, Math.round(num(row.requests)));
+ distribution[
+ FREQUENCY_BINS.findIndex((bin) => downloads <= bin.max)
+ ].ips += 1;
+ }
+
+ return {
+ days: points,
+ totals,
+ users: {
+ // Same population as the histogram, so headline and bars agree.
+ uniqueIps: ipRows.length,
+ registered: num(registeredRows[0]?.registered),
+ anonRequests: num(windowRows[0]?.anon_requests),
+ distribution,
+ },
+ };
+ } catch (error) {
+ LOGGER.warn("Analytics usage query failed", {
+ operation: "getUsage",
+ context: "analytics engine",
+ metadata: { accountId, productId, objectPath, error: String(error) },
+ });
+ return null;
+ }
+}
+
+function parseUsageAggregates(row: Row): UsageTotals {
+ return {
+ bytes: num(row.bytes),
+ requests: num(row.requests),
+ countries: num(row.countries),
+ };
+}
+
+const COUNTRY_LIST_LIMIT = 5;
+const FILE_LIST_LIMIT = 10;
+
+export interface ProductBreakdowns {
+ /** Top countries by downloads */
+ countries: { code: string; name: string; requests: number }[];
+ /** Aggregate of the remaining countries, if any */
+ otherCountries: { count: number; requests: number } | null;
+ /** Top objects by downloads */
+ files: { path: string; requests: number; bytes: number }[];
+}
+
+/**
+ * By-country and top-files breakdowns for the product analytics page.
+ * Same contract as getUsage: null when unconfigured or the query fails.
+ */
+export async function getProductBreakdowns(
+ accountId: string,
+ productId: string,
+ days: UsageWindow,
+): Promise {
+ if (!isAnalyticsConfigured()) return null;
+ const from = usageFrom(accountId, productId, undefined, days);
+
+ try {
+ const [countryRows, fileRows] = await Promise.all([
+ usageQuery(
+ `SELECT blob6 AS country, SUM(_sample_interval) AS requests ${from} GROUP BY country ORDER BY requests DESC`,
+ ),
+ // blob3 = '' is a keyless product GET (trailing-slash/probe requests,
+ // not a real file) — keep those out of the top-files ranking.
+ usageQuery(
+ `SELECT blob3 AS file, SUM(_sample_interval) AS requests, SUM(_sample_interval * double1) AS bytes ${from} AND blob3 != '' GROUP BY file ORDER BY requests DESC LIMIT ${FILE_LIST_LIMIT}`,
+ ),
+ ]);
+
+ const rest = countryRows.slice(COUNTRY_LIST_LIMIT);
+ return {
+ countries: countryRows.slice(0, COUNTRY_LIST_LIMIT).map((row) => ({
+ code: str(row.country) || "??",
+ name: countryName(str(row.country)),
+ requests: num(row.requests),
+ })),
+ otherCountries: rest.length
+ ? {
+ count: rest.length,
+ requests: rest.reduce((sum, row) => sum + num(row.requests), 0),
+ }
+ : null,
+ files: fileRows.map((row) => ({
+ path: str(row.file),
+ requests: num(row.requests),
+ bytes: num(row.bytes),
+ })),
+ };
+ } catch (error) {
+ LOGGER.warn("Analytics breakdown query failed", {
+ operation: "getProductBreakdowns",
+ context: "analytics engine",
+ metadata: { accountId, productId, days, error: String(error) },
+ });
+ return null;
+ }
+}
+
+const countryNames = new Intl.DisplayNames(["en"], { type: "region" });
+
+/** "US" → "United States"; non-ISO values (e.g. "T1", "") fall back to the code. */
+export function countryName(code: string): string {
+ if (!code) return "Unknown";
+ try {
+ return countryNames.of(code) || code;
+ } catch {
+ return code;
+ }
+}
+
+/** "US" → "United States (US)"; non-ISO values (e.g. "T1") pass through. */
+function countryLabel(code: string): string {
+ if (!code) return "(unknown)";
+ const name = countryName(code);
+ return name !== code ? `${name} (${code})` : code;
+}
+
+/** Display key for one grouped row, e.g. "ftw/global-data · United States (US)". */
+function rowKey(row: Row, groupBy: AdminDimension[]): string {
+ return groupBy
+ .map((dim) => {
+ switch (dim) {
+ case "account":
+ return str(row.blob1);
+ case "product":
+ return `${str(row.blob1)}/${str(row.blob2)}`;
+ case "country":
+ return countryLabel(str(row.blob6));
+ case "client":
+ // Full HMAC hex is unwieldy; 12 chars is plenty to tell clients apart.
+ return str(row.blob8).slice(0, 12) || "(unknown)";
+ }
+ })
+ .join(" · ");
+}
+
+/** Parse "YYYY-MM-DD" (UTC day start) or "YYYY-MM-DDTHH:MM" (UTC instant). */
+function utcInstant(
+ value: string,
+): { ms: number; dayGrain: boolean } | null {
+ const dayGrain = /^\d{4}-\d{2}-\d{2}$/.test(value);
+ if (!dayGrain && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) return null;
+ const ms = Date.parse(dayGrain ? `${value}T00:00:00Z` : `${value}:00Z`);
+ return Number.isNaN(ms) ? null : { ms, dayGrain };
+}
+
+const isoDay = (ms: number) => new Date(ms).toISOString().slice(0, 10);
+const isoMinute = (ms: number) => new Date(ms).toISOString().slice(0, 16);
+/** AE DateTime literal body, e.g. "2026-07-07 13:00:00". */
+const sqlDateTime = (ms: number) =>
+ new Date(ms).toISOString().slice(0, 19).replace("T", " ");
+
+/**
+ * WHERE clause per filterable dimension. Countries are stored as uppercase
+ * ISO codes. IP hashes prefix-match (LIKE is in the AE pattern-matching
+ * operators) because the UI surfaces only the first 12 hash characters;
+ * LIKE wildcards in the value are escaped.
+ */
+const FILTER_SQL: Record string> = {
+ account: (value) => `blob1 = ${sqlQuote(value)}`,
+ product: (value) => `blob2 = ${sqlQuote(value)}`,
+ country: (value) => `blob6 = ${sqlQuote(value.toUpperCase())}`,
+ client: (value) =>
+ `blob8 LIKE ${sqlQuote(`${value.replace(/[\\%_]/g, (m) => `\\${m}`)}%`)}`,
+};
+
+/**
+ * Traffic over an inclusive UTC day range, bucketed for a stacked timeseries
+ * and grouped by zero or more dimensions. Group cardinality is unbounded
+ * (client IP hashes especially), so this never fetches all groups: a totals
+ * query finds the top groups, the timeseries query is filtered to the
+ * chart's top slice, and a bucket-totals query provides the "Other"
+ * remainder per bucket.
+ *
+ * Returns null when analytics is unconfigured; throws on query failure (the
+ * admin page surfaces errors rather than hiding them).
+ */
+export async function getAdminBreakdown(
+ query: AdminQuery,
+): Promise {
+ if (!isAnalyticsConfigured()) return null;
+
+ // Resolve the range: default to the last 7 days, clamp to [retention,
+ // now], swap reversed bounds. Internally the range is [fromMs, endMs) —
+ // a day-grained `to` is inclusive, a time-grained `to` IS the end.
+ const today = new Date().setUTCHours(0, 0, 0, 0);
+ let a = utcInstant(query.from) ?? { ms: today - 6 * DAY_MS, dayGrain: true };
+ let b = utcInstant(query.to) ?? { ms: today, dayGrain: true };
+ if (a.ms > b.ms) [a, b] = [b, a];
+ let fromMs = a.ms;
+ let endMs = b.dayGrain ? b.ms + DAY_MS : b.ms;
+ if (endMs <= fromMs) endMs = fromMs + DAY_MS;
+ endMs = Math.min(endMs, today + DAY_MS);
+ fromMs = Math.min(
+ Math.max(fromMs, today - RETENTION_DAYS * DAY_MS),
+ endMs - 1,
+ );
+ // Grain is decided by alignment, not input format: a datetime pair that
+ // lands on midnights (e.g. the filter form's T00:00 bounds) is a day
+ // range — it keeps the proven NOW()-relative SQL and the day-grained
+ // range echo. Both clamps are day-aligned, so alignment survives them.
+ const dayGrained = fromMs % DAY_MS === 0 && endMs % DAY_MS === 0;
+
+ // Bucket size: an explicit whitelisted interval, else auto by range
+ // length. Either way escalate until the bar count stays drawable —
+ // hourly over 92 days would be ~2,200 stacked bars.
+ const DAY_MIN = 1440;
+ const rangeMinutes = (endMs - fromMs) / 60_000;
+ const BUCKET_LADDER = [1, 15, 60, 180, 360, 1440, 4320, 10080];
+ let bucketMinutes = BUCKET_INTERVALS.some(
+ (bucket) => bucket.minutes === query.bucketMinutes,
+ )
+ ? (query.bucketMinutes as number)
+ : rangeMinutes <= 120 ? 1
+ : rangeMinutes <= DAY_MIN ? 60
+ : rangeMinutes <= 3 * DAY_MIN ? 180
+ : rangeMinutes <= 7 * DAY_MIN ? 360
+ : rangeMinutes <= 31 * DAY_MIN ? 1440
+ : 4320;
+ while (rangeMinutes / bucketMinutes > MAX_CHART_BUCKETS) {
+ const next = BUCKET_LADDER.find((m) => m > bucketMinutes);
+ if (!next) break;
+ bucketMinutes = next;
+ }
+
+ const groupBy = [...new Set(query.groupBy)];
+ const filters = [SERVED_FILTER];
+ if (dayGrained) {
+ // Day offsets relative to NOW() keep the SQL in the
+ // toStartOfDay(NOW() - INTERVAL) form AE's strict validator accepts.
+ const fromDaysAgo = Math.round((today - fromMs) / DAY_MS);
+ filters.push(
+ `timestamp >= toStartOfDay(NOW() - INTERVAL '${fromDaysAgo}' DAY)`,
+ );
+ if (endMs <= today) {
+ const endDaysAgo = Math.round((today - endMs) / DAY_MS);
+ filters.push(
+ `timestamp < toStartOfDay(NOW() - INTERVAL '${endDaysAgo}' DAY)`,
+ );
+ }
+ } else {
+ // Sub-day bounds (drill-down): absolute UTC literals via toDateTime,
+ // which the AE docs show accepting 'YYYY-MM-DD hh:mm:ss'.
+ filters.push(
+ `timestamp >= toDateTime('${sqlDateTime(fromMs)}')`,
+ `timestamp < toDateTime('${sqlDateTime(endMs)}')`,
+ );
+ }
+ for (const [dim, value] of Object.entries(query.filters ?? {})) {
+ filters.push(FILTER_SQL[dim as AdminDimension](value));
+ }
+ const from = `FROM ${CONFIG.analytics.dataset} WHERE ${filters.join(" AND ")}`;
+
+ const aggregates = `SUM(_sample_interval * double1) AS bytes, SUM(_sample_interval) AS requests`;
+ // Sub-hour buckets use AE's dedicated toStartOf* functions (documented,
+ // unlike MINUTE units for toStartOfInterval); hour multiples below a day
+ // use the proven toStartOfInterval HOUR form; day multiples group by day
+ // in SQL and get folded into buckets here, aligned to the range start
+ // (AE silently degrades >24h hour intervals to daily).
+ const dayFold = bucketMinutes >= DAY_MIN;
+ const bucketExpr =
+ bucketMinutes === 1
+ ? "toStartOfMinute(timestamp)"
+ : bucketMinutes === 15
+ ? "toStartOfFifteenMinutes(timestamp)"
+ : dayFold
+ ? "toStartOfDay(timestamp)"
+ : `toStartOfInterval(timestamp, INTERVAL '${bucketMinutes / 60}' HOUR)`;
+
+ const columns = [...new Set(groupBy.flatMap((d) => ADMIN_DIMENSIONS[d].columns))];
+
+ // Per-bucket overall totals — the chart's "Other" baseline and grand total.
+ const bucketTotalsSql = `SELECT ${bucketExpr} AS bucket, ${aggregates} ${from} GROUP BY bucket ORDER BY bucket`;
+ const rankBy = query.metric === "bytes" ? "bytes" : "requests";
+ const groupTotalsSql = columns.length
+ ? `SELECT ${columns.join(", ")}, ${aggregates} ${from} GROUP BY ${columns.join(", ")} ORDER BY ${rankBy} DESC LIMIT ${TABLE_GROUP_LIMIT}`
+ : null;
+ // Headline uniques; blob8 = '' (IP unknown) is a value, not an IP, so
+ // detect it and drop it from the distinct count.
+ const distinctSql = `SELECT COUNT(DISTINCT blob6) AS countries, COUNT(DISTINCT blob8) AS ips, sumIf(_sample_interval, blob8 = '') AS no_ip ${from}`;
+ const queries = [
+ bucketTotalsSql,
+ ...(groupTotalsSql ? [groupTotalsSql] : []),
+ distinctSql,
+ ];
+
+ const [bucketTotals, groupTotals, distinctRows] = await Promise.all([
+ adminQuery(bucketTotalsSql),
+ groupTotalsSql ? adminQuery(groupTotalsSql) : Promise.resolve([]),
+ adminQuery(distinctSql),
+ ]);
+
+ const distinct = distinctRows[0] ?? {};
+ const totals = {
+ bytes: bucketTotals.reduce((sum, row) => sum + num(row.bytes), 0),
+ requests: bucketTotals.reduce((sum, row) => sum + num(row.requests), 0),
+ uniqueIps: Math.max(0, num(distinct.ips) - (num(distinct.no_ip) > 0 ? 1 : 0)),
+ countries: num(distinct.countries),
+ };
+
+ // Zero-filled bucket grid anchored at the range start (day starts are also
+ // on AE's epoch-aligned sub-daily grid, since 1/15/60/180/360 minutes all
+ // divide a day); union in any returned bucket that lands off-grid so data
+ // is never dropped.
+ const bucketMs = bucketMinutes * 60_000;
+ const gridEnd = Math.min(Date.now(), endMs - 1);
+ const grid = new Set();
+ for (let t = fromMs; t <= gridEnd; t += bucketMs) {
+ grid.add(new Date(t).toISOString());
+ }
+ const fold = (iso: string): string => {
+ if (!dayFold) return iso;
+ const step = Math.max(0, Math.floor((Date.parse(iso) - fromMs) / bucketMs));
+ return new Date(fromMs + step * bucketMs).toISOString();
+ };
+
+ const totalByBucket = new Map();
+ for (const row of bucketTotals) {
+ const bucket = fold(parseDateTime(row.bucket));
+ const acc = totalByBucket.get(bucket) ?? { bytes: 0, requests: 0 };
+ acc.bytes += num(row.bytes);
+ acc.requests += num(row.requests);
+ totalByBucket.set(bucket, acc);
+ }
+ for (const bucket of totalByBucket.keys()) grid.add(bucket);
+ const buckets = [...grid].sort();
+
+ const range = dayGrained
+ ? { from: isoDay(fromMs), to: isoDay(endMs - DAY_MS) }
+ : { from: isoMinute(fromMs), to: isoMinute(endMs) };
+
+ if (!columns.length) {
+ // No grouping: a single "All traffic" series.
+ const key = "All traffic";
+ return {
+ buckets,
+ bucketMinutes,
+ range,
+ series: [key],
+ points: buckets.map((b): AdminBreakdown["points"][number] => {
+ const row = totalByBucket.get(b);
+ return row ? { [key]: row } : {};
+ }),
+ groups:
+ totals.bytes || totals.requests
+ ? [{ key, bytes: totals.bytes, requests: totals.requests }]
+ : [],
+ totals,
+ queries,
+ };
+ }
+
+ const groups = groupTotals.map((row) => ({
+ key: rowKey(row, groupBy),
+ bytes: num(row.bytes),
+ requests: num(row.requests),
+ }));
+
+ // Timeseries only for the chart's top slice, matched on the raw columns.
+ const chartRows = groupTotals.slice(0, CHART_SERIES_LIMIT);
+ let seriesRows: Row[] = [];
+ if (chartRows.length) {
+ const match = chartRows
+ .map(
+ (row) =>
+ `(${columns.map((c) => `${c} = ${sqlQuote(str(row[c]))}`).join(" AND ")})`,
+ )
+ .join(" OR ");
+ const seriesSql = `SELECT ${bucketExpr} AS bucket, ${columns.join(", ")}, ${aggregates} ${from} AND (${match}) GROUP BY bucket, ${columns.join(", ")} ORDER BY bucket`;
+ queries.push(seriesSql);
+ seriesRows = await adminQuery(seriesSql);
+ }
+
+ const chartKeys = chartRows.map((row) => rowKey(row, groupBy));
+ const points: AdminBreakdown["points"] = buckets.map(() => ({}));
+ const bucketIndex = new Map(buckets.map((b, i) => [b, i]));
+ for (const row of seriesRows) {
+ const i = bucketIndex.get(fold(parseDateTime(row.bucket)));
+ if (i === undefined) continue;
+ const key = rowKey(row, groupBy);
+ const acc = points[i][key] ?? { bytes: 0, requests: 0 };
+ points[i][key] = {
+ bytes: acc.bytes + num(row.bytes),
+ requests: acc.requests + num(row.requests),
+ };
+ }
+
+ // "Other" per bucket = overall minus the charted slice (clamped: sampling
+ // estimates can put the slice a hair above the total). Gate on either
+ // metric — a remainder can be requests-only (zero-byte 200s pass the
+ // served filter) and must still show in the Requests view.
+ let hasOther = false;
+ buckets.forEach((bucket, i) => {
+ const total = totalByBucket.get(bucket);
+ if (!total) return;
+ const charted = Object.values(points[i]).reduce(
+ (acc, v) => ({ bytes: acc.bytes + v.bytes, requests: acc.requests + v.requests }),
+ { bytes: 0, requests: 0 },
+ );
+ const other = {
+ bytes: Math.max(0, total.bytes - charted.bytes),
+ requests: Math.max(0, total.requests - charted.requests),
+ };
+ if (other.bytes > 0 || other.requests > 0) {
+ points[i][OTHER_KEY] = other;
+ hasOther = true;
+ }
+ });
+ const series = hasOther ? [...chartKeys, OTHER_KEY] : chartKeys;
+
+ // Table remainder beyond the top TABLE_GROUP_LIMIT groups.
+ const remainder = {
+ bytes: Math.max(0, totals.bytes - groups.reduce((sum, g) => sum + g.bytes, 0)),
+ requests: Math.max(
+ 0,
+ totals.requests - groups.reduce((sum, g) => sum + g.requests, 0),
+ ),
+ };
+ if (remainder.bytes > 0 || remainder.requests > 0) {
+ groups.push({ key: OTHER_KEY, ...remainder });
+ }
+
+ return { buckets, bucketMinutes, range, series, points, groups, totals, queries };
+}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 2c4fa709..616ad3ef 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -66,6 +66,14 @@ export const CONFIG = {
},
},
+ // Cloudflare Analytics Engine — request analytics written by the data proxy.
+ // All three must be set for analytics UI to appear; otherwise it hides itself.
+ analytics: {
+ accountId: process.env.CF_ANALYTICS_ACCOUNT_ID || "",
+ apiToken: process.env.CF_ANALYTICS_API_TOKEN || "",
+ dataset: process.env.CF_ANALYTICS_DATASET || "",
+ },
+
// Location WebSocket for live globe
locationWs: {
url: process.env.NEXT_PUBLIC_LOCATION_WS_URL,
diff --git a/src/lib/format.ts b/src/lib/format.ts
index cc4fcb30..092bb324 100644
--- a/src/lib/format.ts
+++ b/src/lib/format.ts
@@ -1,14 +1,15 @@
/**
* Format a number of bytes into a human-readable string
* @param bytes The number of bytes to format
+ * @param decimals Maximum decimal places (default 2)
* @returns A formatted string with appropriate units
*/
-export function formatBytes(bytes: number): string {
+export function formatBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
}
/**
diff --git a/src/lib/urls.ts b/src/lib/urls.ts
index 0fff5907..8ddff493 100644
--- a/src/lib/urls.ts
+++ b/src/lib/urls.ts
@@ -17,6 +17,7 @@ export const docsUrl = () => "https://docs.source.coop";
// Admin URLs
export const adminUrl = () => "/admin";
+export const adminAnalyticsUrl = () => "/admin/analytics";
export const adminUserLookupUrl = () => "/admin/user-lookup";
export const adminDataConnectionsUrl = () => "/admin/data-connections";
export const adminDataConnectionCreateUrl = () =>
@@ -43,6 +44,13 @@ export const loginUrl = (returnTo?: string) => {
};
export const onboardingUrl = () => "/onboarding";
+// Product analytics (maintainers/owners/admins). The query param is
+// rewritten by middleware to the internal /-/analytics route, so object
+// paths can never be shadowed and layouts (which can't read search params)
+// stay out of the loop.
+export const productAnalyticsUrl = (account_id: string, product_id: string) =>
+ `/${account_id}/${product_id}?tab=analytics`;
+
// Object URLs
export const objectUrl = (
account_id: string,
diff --git a/src/middleware.test.ts b/src/middleware.test.ts
new file mode 100644
index 00000000..e9cdf1bf
--- /dev/null
+++ b/src/middleware.test.ts
@@ -0,0 +1,41 @@
+/**
+ * The ?tab=analytics → /-/analytics rewrite is invisible indirection whose
+ * failure mode is a silently dead ANALYTICS tab — pin its behavior.
+ */
+import { NextRequest } from "next/server";
+import { handleProductAnalyticsTab } from "./middleware";
+
+const rewriteTarget = (url: string): string | null =>
+ handleProductAnalyticsTab(new NextRequest(url))?.headers.get(
+ "x-middleware-rewrite",
+ ) ?? null;
+
+it("rewrites the product analytics tab URL to the internal route", () => {
+ expect(rewriteTarget("https://source.coop/acct/prod?tab=analytics")).toBe(
+ "https://source.coop/acct/prod/-/analytics",
+ );
+});
+
+it("preserves other query params and drops tab", () => {
+ expect(
+ rewriteTarget("https://source.coop/acct/prod?tab=analytics&window=7"),
+ ).toBe("https://source.coop/acct/prod/-/analytics?window=7");
+});
+
+it("ignores non-matching requests", () => {
+ // No tab param / wrong value
+ expect(rewriteTarget("https://source.coop/acct/prod")).toBeNull();
+ expect(rewriteTarget("https://source.coop/acct/prod?tab=other")).toBeNull();
+ // Not a two-segment product path
+ expect(rewriteTarget("https://source.coop/acct?tab=analytics")).toBeNull();
+ expect(
+ rewriteTarget("https://source.coop/acct/prod/file.txt?tab=analytics"),
+ ).toBeNull();
+ // Two-segment top-level app routes are not products
+ expect(
+ rewriteTarget("https://source.coop/admin/analytics?tab=analytics"),
+ ).toBeNull();
+ expect(
+ rewriteTarget("https://source.coop/products/new?tab=analytics"),
+ ).toBeNull();
+});
diff --git a/src/middleware.ts b/src/middleware.ts
index 3e25ea82..e4ff51ba 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -63,6 +63,47 @@ const handleLegacyRedirects = (request: NextRequest): NextResponse | null => {
return null;
};
+// Top-level routes that share the /{segment}/{segment} shape but are not
+// account/product pages — the analytics rewrite must leave them alone.
+const NON_ACCOUNT_SEGMENTS = new Set([
+ "admin",
+ "edit",
+ "email-verified",
+ "feed.xml",
+ "featured",
+ "logout",
+ "onboarding",
+ "products",
+ "repositories",
+]);
+
+/**
+ * Serve the maintainer analytics view on the product root via a query param
+ * (`/{account}/{product}?tab=analytics`). Layouts can't read search params,
+ * so the view lives at the internal `/-/analytics` route (which also keeps
+ * it from shadowing real object paths) and the query-param URL is rewritten
+ * to it here. Other params (e.g. `window`) pass through.
+ *
+ * Exported for tests: the failure mode is a silently dead ANALYTICS tab.
+ */
+export const handleProductAnalyticsTab = (
+ request: NextRequest,
+): NextResponse | null => {
+ const { pathname, searchParams } = request.nextUrl;
+ const match = pathname.match(/^\/([^/]+)\/[^/]+$/);
+ if (
+ searchParams.get("tab") === "analytics" &&
+ match &&
+ !NON_ACCOUNT_SEGMENTS.has(match[1])
+ ) {
+ const url = request.nextUrl.clone();
+ url.pathname = `${pathname}/-/analytics`;
+ url.searchParams.delete("tab");
+ return NextResponse.rewrite(url);
+ }
+ return null;
+};
+
const ory = createOryMiddleware({});
// Paths the Ory middleware proxies (self-service flows, session checks). For
@@ -83,7 +124,9 @@ export const middleware = async (request: NextRequest) => {
}
// Let Ory handle its own endpoints (it returns proxied responses with
- // redirects / Set-Cookie that we must not discard).
+ // redirects / Set-Cookie that we must not discard). This runs before the
+ // analytics rewrite so two-segment Ory paths (e.g. /sessions/whoami) can
+ // never be diverted by a stray ?tab=analytics.
if (
ORY_PROXIED_PREFIXES.some((prefix) =>
request.nextUrl.pathname.startsWith(prefix),
@@ -92,9 +135,18 @@ export const middleware = async (request: NextRequest) => {
return ory(request);
}
+ const analyticsRewrite = handleProductAnalyticsTab(request);
+ if (analyticsRewrite) return analyticsRewrite;
+
return NextResponse.next();
};
export const config = {
- matcher: ["/((?!api|_next/static|_next/image|favicon.ico|logo|favicon).*)"],
+ // Exclusions are anchored as directories/files — an unanchored prefix
+ // (e.g. `api`) would also skip middleware for any account whose id merely
+ // starts with it (apiuser, logophile, …), silently disabling rewrites
+ // and Ory handling on their pages.
+ matcher: [
+ "/((?!api/|_next/static|_next/image|favicon\\.ico|favicon/|logo/).*)",
+ ],
};