From a2c9fda65ccdd944f87f3bfed9c93b77113c9f9b Mon Sep 17 00:00:00 2001 From: CollinsKRO Date: Tue, 30 Jun 2026 10:10:13 +0000 Subject: [PATCH 1/3] . --- .prettierrc.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.prettierrc.json b/.prettierrc.json index 8a0f27e..93520a5 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -3,4 +3,5 @@ "singleQuote": false, "trailingComma": "none", "printWidth": 100 + } From e44e855b80b11048983cd64a847b12a6648a9fbf Mon Sep 17 00:00:00 2001 From: CollinsKRO Date: Tue, 30 Jun 2026 10:13:15 +0000 Subject: [PATCH 2/3] . --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index c37a750..70da5e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,4 +1,5 @@ { + "name": "query402", "version": "0.1.0", "lockfileVersion": 3, From 4eb4ddd347844520a14920e5001f8b7f5fe3c030 Mon Sep 17 00:00:00 2001 From: CollinsKRO Date: Mon, 6 Jul 2026 09:50:28 +0000 Subject: [PATCH 3/3] Add reviewer-safe JSON export endpoint for usage and spend analytics - Add GET /api/export endpoint returning sanitized analytics - Export includes: total queries, spend by mode/provider, demo/paid/failed counts, payment evidence count, timestamp - No raw payment headers, wallet secrets, or provider credentials - Empty analytics returns valid export with zero totals - Tests assert secret-like fields are excluded - Add Download JSON button to ControlDeckPage --- apps/api/src/lib/persistence.ts | 60 ++++++++++++++ apps/api/src/routes/public.test.ts | 109 ++++++++++++++++++++++++- apps/api/src/routes/public.ts | 11 ++- apps/web/src/pages/ControlDeckPage.tsx | 25 ++++++ apps/web/src/styles.css | 21 +++++ 5 files changed, 224 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index 6dcc79c..1ff6fc6 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -110,6 +110,66 @@ export async function getAnalyticsSummary( return getStorageRepository().getAnalyticsSummary(options); } +export interface AnalyticsExport { + exportedAt: string; + totalQueries: number; + totalSpendUsd: number; + spendByMode: Record; + spendByProvider: Record; + queryCountByStatus: { + demo: number; + paid: number; + failed: number; + }; + paymentEvidenceCount: number; +} + +export async function getAnalyticsExport(): Promise { + const usage = await getUsageEvents(); + const payments = await getPaymentAttempts(); + + const exportedAt = new Date().toISOString(); + const totalQueries = usage.length; + + const spendByMode: Record = { search: 0, news: 0, scrape: 0 }; + const spendByProvider: Record = {}; + const queryCountByStatus = { demo: 0, paid: 0, failed: 0 }; + + for (const event of usage) { + spendByMode[event.mode] += event.priceUsd; + spendByProvider[event.providerId] = + (spendByProvider[event.providerId] ?? 0) + event.priceUsd; + + if (event.paymentStatus === "demo-paid") { + queryCountByStatus.demo++; + } else if (event.paymentStatus === "failed") { + queryCountByStatus.failed++; + } else { + queryCountByStatus.paid++; + } + } + + const totalSpendUsd = Number( + Object.values(spendByMode) + .reduce((sum, v) => sum + v, 0) + .toFixed(6) + ); + + const paymentEvidenceCount = payments.filter( + (p) => p.transactionHash || p.facilitatorResult || p.evidenceKind + ).length; + + return { + exportedAt, + totalQueries, + totalSpendUsd, + spendByMode, + spendByProvider, + queryCountByStatus, + paymentEvidenceCount + }; +} + export async function persistPaidRequest(input: PersistPaidRequestInput): Promise { const payment = buildPaymentAttempt(input); const usage = buildUsageEvent(input, { diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index a9262cf..3d50c7f 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -1,7 +1,7 @@ import express from "express"; import request from "supertest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { buildTestUsageEvent } from "../test/storage-test-helpers.js"; +import { buildTestPaymentAttempt, buildTestUsageEvent } from "../test/storage-test-helpers.js"; import { applyApiTestEnv, resetApiTestStorage } from "../test/api-test-helpers.js"; describe("public routes", () => { @@ -259,4 +259,111 @@ describe("public routes", () => { expect(analyticsResponse.body.totalSpendUsd).toBe(0.01); expect(analyticsResponse.body.spendByCategory.search).toBe(0.01); }); + + describe("GET /api/export — sanitized analytics export", () => { + it("returns valid export with zero totals for fresh storage", async () => { + const app = await createPublicApp(); + const response = await request(app).get("/api/export"); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + totalQueries: 0, + totalSpendUsd: 0, + spendByMode: { search: 0, news: 0, scrape: 0 }, + spendByProvider: {}, + queryCountByStatus: { demo: 0, paid: 0, failed: 0 }, + paymentEvidenceCount: 0 + }); + expect(typeof response.body.exportedAt).toBe("string"); + expect(() => new Date(response.body.exportedAt)).not.toThrow(); + }); + + it("reflects saved usage data in the export", async () => { + const app = await createPublicApp(); + const { saveUsageEvent, savePaymentAttempt } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_export_1", + mode: "search", + providerId: "search.basic", + paymentStatus: "settled", + priceUsd: 0.05, + createdAt: "2026-06-21T10:00:00.000Z" + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_export_2", + mode: "news", + providerId: "news.fast", + paymentStatus: "demo-paid", + priceUsd: 0.03, + createdAt: "2026-06-21T11:00:00.000Z" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_export_1", + transactionHash: "tx_abc123", + evidenceKind: "settled" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_export_2", + transactionHash: undefined, + facilitatorResult: undefined, + evidenceKind: undefined + }) + ); + + const response = await request(app).get("/api/export"); + expect(response.status).toBe(200); + + expect(response.body.totalQueries).toBe(2); + expect(response.body.totalSpendUsd).toBe(0.08); + expect(response.body.spendByMode).toEqual({ search: 0.05, news: 0.03, scrape: 0 }); + expect(response.body.spendByProvider).toEqual({ + "search.basic": 0.05, + "news.fast": 0.03 + }); + expect(response.body.queryCountByStatus).toEqual({ demo: 1, paid: 1, failed: 0 }); + expect(response.body.paymentEvidenceCount).toBe(1); + }); + + it("excludes secret-like fields from the response body", async () => { + const app = await createPublicApp(); + const { saveUsageEvent, savePaymentAttempt } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_secret_1", + payerPublicKey: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + facilitatorUrl: "https://channels.openzeppelin.com/x402/testnet" + }) + ); + await savePaymentAttempt( + buildTestPaymentAttempt({ + id: "pay_secret_1", + payerPublicKey: "GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890", + facilitatorResult: { rawHeader: "should-not-leak" } + }) + ); + + const response = await request(app).get("/api/export"); + expect(response.status).toBe(200); + + const body = JSON.stringify(response.body); + expect(body).not.toContain("GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"); + expect(body).not.toContain("rawHeader"); + expect(body).not.toContain("should-not-leak"); + expect(body).not.toContain("facilitatorUrl"); + expect(body).not.toContain("payerPublicKey"); + expect(body).not.toContain("transactionHash"); + expect(body).not.toContain("facilitatorResult"); + expect(body).not.toContain("paymentResponseHeader"); + expect(body).not.toContain("payToAddress"); + }); + }); }); diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index d0d4b5b..d026641 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { z } from "zod"; import { providers } from "../lib/pricing.js"; -import { getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js"; +import { getAnalyticsExport, getAnalyticsSummary, getUsageEvents } from "../lib/persistence.js"; import { config, getConfigSnapshot } from "../lib/config.js"; import { apiVersion } from "../lib/build-metadata.js"; import { getCatalog } from "../services/query-service.js"; @@ -66,6 +66,15 @@ publicRouter.get("/api/usage", async (req, res, next) => { } }); +publicRouter.get("/api/export", async (_req, res, next) => { + try { + const exportData = await getAnalyticsExport(); + res.json(exportData); + } catch (error) { + next(error); + } +}); + publicRouter.get("/api/analytics", async (req, res, next) => { try { const parsed = analyticsQuerySchema.safeParse(req.query); diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index 78144f6..98bd5b8 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -5,6 +5,7 @@ import { CheckCircle2, CircleDollarSign, Clock4, + Download, Gauge, Home, Radar, @@ -710,6 +711,30 @@ export default function ControlDeckPage() { )} + + diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c7ecfd1..1b97e20 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1260,6 +1260,27 @@ body { line-height: 1.5; } +.export-btn { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + margin-top: 0.5rem; + padding: 0.5rem 0.72rem; + border: 1px solid rgba(129, 160, 201, 0.24); + border-radius: 14px; + background: rgba(11, 16, 26, 0.84); + color: #8ca0bc; + font-size: 0.78rem; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.export-btn:hover { + background: rgba(129, 160, 201, 0.12); + color: #e8f2ff; +} + @media (max-width: 1140px) { .landing-hero { grid-template-columns: 1fr;