From 946747a00b139d3e34ea00920fa54a32829f32cc Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:54:41 +0000 Subject: [PATCH 1/2] feat(api-request-log): re-enable logging, increase retention to 30 days, add admin download panel - Uncomment handleRequestLogging call in openrouter proxy route - Increase cleanup cron retention from 7 to 30 days - Add admin page with user ID and date range filters - Stream zip file with formatted request/response JSON files - Add archiver dependency for server-side zip streaming --- package.json | 8 +- pnpm-lock.yaml | 146 +++++++++++++++++- src/app/admin/api-request-log/page.tsx | 121 +++++++++++++++ .../api/api-request-log/download/route.ts | 123 +++++++++++++++ src/app/admin/components/AppSidebar.tsx | 5 + .../api/cron/cleanup-api-request-log/route.ts | 2 +- src/app/api/openrouter/[...path]/route.ts | 3 +- 7 files changed, 400 insertions(+), 8 deletions(-) create mode 100644 src/app/admin/api-request-log/page.tsx create mode 100644 src/app/admin/api/api-request-log/download/route.ts diff --git a/package.json b/package.json index d7021184a..c192deb19 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@trpc/client": "catalog:", "@trpc/server": "catalog:", "@trpc/tanstack-react-query": "catalog:", + "@types/archiver": "^7.0.0", "@types/js-cookie": "^3.0.6", "@types/js-yaml": "^4.0.9", "@types/mdx": "^2.0.13", @@ -100,6 +101,7 @@ "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", "ai": "^6.0.116", + "archiver": "^7.0.1", "chat": "^4.20.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -136,9 +138,9 @@ "remark-gfm": "^4.0.1", "server-only": "^0.0.1", "sonner": "^2.0.7", - "stripe": "catalog:", "stream-chat": "^9.38.0", "stream-chat-react": "^13.14.2", + "stripe": "catalog:", "stytch": "^12.43.1", "tailwind-merge": "^3.5.0", "uuid": "11.1.0", @@ -168,6 +170,7 @@ "madge": "^8.0.0", "oxfmt": "^0.40.0", "oxlint": "^1.55.0", + "oxlint-plugin-react-native": "^0.2.8", "oxlint-tsgolint": "^0.17.1", "p-limit": "^7.3.0", "postcss": "^8.5.8", @@ -176,8 +179,7 @@ "tsconfig-paths": "^4.2.0", "tsx": "^4.21.0", "tw-animate-css": "^1.4.0", - "typescript": "catalog:", - "oxlint-plugin-react-native": "^0.2.8" + "typescript": "catalog:" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9caf244b..a3256aff8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -254,6 +254,9 @@ importers: '@trpc/tanstack-react-query': specifier: 'catalog:' version: 11.13.0(@tanstack/react-query@5.90.21(react@19.2.4))(@trpc/client@11.13.0(@trpc/server@11.13.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.13.0(typescript@5.9.3))(react@19.2.4)(typescript@5.9.3) + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/js-cookie': specifier: ^3.0.6 version: 3.0.6 @@ -284,6 +287,9 @@ importers: ai: specifier: ^6.0.116 version: 6.0.116(zod@4.3.6) + archiver: + specifier: ^7.0.1 + version: 7.0.1 chat: specifier: ^4.20.1 version: 4.20.1 @@ -1503,7 +1509,7 @@ importers: version: 4.0.2 knip: specifier: ^5.86.0 - version: 5.86.0(@types/node@22.19.15)(typescript@5.9.3) + version: 5.86.0(@types/node@25.5.0)(typescript@5.9.3) typescript: specifier: 'catalog:' version: 5.9.3 @@ -6933,6 +6939,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -7085,6 +7094,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -7696,6 +7708,14 @@ packages: resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==} engines: {node: '>=8'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + archy@1.0.0: resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==} @@ -7747,6 +7767,9 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -7993,6 +8016,9 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.4: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} @@ -8039,6 +8065,10 @@ packages: bser@2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -8341,6 +8371,10 @@ packages: commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -8425,6 +8459,10 @@ packages: engines: {node: '>=0.8'} hasBin: true + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + create-ecdh@4.0.4: resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} @@ -10656,6 +10694,10 @@ packages: resolution: {integrity: sha512-EZgbsXMrGS+oK+Ta12mCjzBFse+SIewGdwrSTr5g+MSymnjpox2x05ceI20PQejJOFvOgzcXrfDk/SdY7dSCtw==} hasBin: true + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -11454,6 +11496,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -12477,6 +12523,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -14161,6 +14210,10 @@ packages: youch@4.1.0-beta.10: resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -20689,6 +20742,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -20868,6 +20925,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 22.19.15 + '@types/resolve@1.20.6': {} '@types/retry@0.12.0': {} @@ -21204,7 +21265,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/ui@3.2.4)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -21484,6 +21545,30 @@ snapshots: dependencies: default-require-extensions: 3.0.1 + archiver-utils@5.0.2: + dependencies: + glob: 13.0.6 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.23 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.8 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + archy@1.0.0: {} arg@5.0.2: {} @@ -21534,6 +21619,8 @@ snapshots: astring@1.9.0: {} + async@3.2.6: {} + asynckit@0.4.0: {} attr-accept@2.2.5: {} @@ -21844,6 +21931,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.4: dependencies: balanced-match: 4.0.4 @@ -21916,6 +22007,8 @@ snapshots: dependencies: node-int64: 0.4.0 + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} @@ -22205,6 +22298,14 @@ snapshots: commondir@1.0.1: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -22286,6 +22387,11 @@ snapshots: crc-32@1.2.2: {} + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + create-ecdh@4.0.4: dependencies: bn.js: 4.12.3 @@ -25085,8 +25191,30 @@ snapshots: yaml: 2.8.2 zod: 4.3.6 + knip@5.86.0(@types/node@25.5.0)(typescript@5.9.3): + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@types/node': 25.5.0 + fast-glob: 3.3.3 + formatly: 0.3.0 + jiti: 2.6.1 + minimist: 1.2.8 + oxc-resolver: 11.19.1 + picocolors: 1.1.1 + picomatch: 4.0.3 + smol-toml: 1.6.0 + strip-json-comments: 5.0.3 + typescript: 5.9.3 + unbash: 2.2.0 + yaml: 2.8.2 + zod: 4.3.6 + lan-network@0.2.0: {} + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leven@3.1.0: {} lighthouse-logger@1.4.2: @@ -26304,6 +26432,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + minimist@1.2.8: {} minimisted@2.0.1: @@ -27564,6 +27696,10 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -29700,6 +29836,12 @@ snapshots: cookie: 1.1.1 youch-core: 0.3.3 + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/src/app/admin/api-request-log/page.tsx b/src/app/admin/api-request-log/page.tsx new file mode 100644 index 000000000..0e6ba40ad --- /dev/null +++ b/src/app/admin/api-request-log/page.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useState } from 'react'; +import { format, subDays } from 'date-fns'; +import AdminPage from '@/app/admin/components/AdminPage'; +import { BreadcrumbItem } from '@/components/ui/breadcrumb'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Download } from 'lucide-react'; + +export default function ApiRequestLogPage() { + const today = format(new Date(), 'yyyy-MM-dd'); + const weekAgo = format(subDays(new Date(), 7), 'yyyy-MM-dd'); + + const [userId, setUserId] = useState(''); + const [startDate, setStartDate] = useState(weekAgo); + const [endDate, setEndDate] = useState(today); + const [downloading, setDownloading] = useState(false); + const [error, setError] = useState(null); + + async function handleDownload() { + if (!userId.trim()) { + setError('User ID is required'); + return; + } + if (!startDate || !endDate) { + setError('Both start and end dates are required'); + return; + } + + setError(null); + setDownloading(true); + + try { + const params = new URLSearchParams({ + userId: userId.trim(), + startDate, + endDate, + }); + + const response = await fetch(`/admin/api/api-request-log/download?${params}`); + + if (!response.ok) { + const body = await response.json().catch(() => null); + setError(body?.error ?? `Download failed (${response.status})`); + return; + } + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = + response.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] ?? + 'api-request-log.zip'; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + setError(e instanceof Error ? e.message : 'Download failed'); + } finally { + setDownloading(false); + } + } + + return ( + API Request Log} + > +
+ + + Download API Request Log + + +
+ + setUserId(e.target.value)} + /> +
+ +
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + {error &&

{error}

} + + +
+
+
+
+ ); +} diff --git a/src/app/admin/api/api-request-log/download/route.ts b/src/app/admin/api/api-request-log/download/route.ts new file mode 100644 index 000000000..5717e7dca --- /dev/null +++ b/src/app/admin/api/api-request-log/download/route.ts @@ -0,0 +1,123 @@ +import type { NextRequest } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { db } from '@/lib/drizzle'; +import { api_request_log } from '@kilocode/db/schema'; +import { and, gte, lte, eq, asc } from 'drizzle-orm'; +import archiver from 'archiver'; +import { PassThrough } from 'node:stream'; + +export const dynamic = 'force-dynamic'; + +function formatTimestamp(isoString: string): string { + return isoString.replace(/[:.]/g, '-').replace('T', '_').replace('Z', ''); +} + +function tryFormatJson(value: unknown): string { + if (typeof value === 'string') { + try { + return JSON.stringify(JSON.parse(value), null, 2); + } catch { + return value; + } + } + if (value !== null && value !== undefined) { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } + } + return ''; +} + +function isJson(value: unknown): boolean { + if (typeof value === 'object' && value !== null) return true; + if (typeof value === 'string') { + try { + JSON.parse(value); + return true; + } catch { + return false; + } + } + return false; +} + +export async function GET(request: NextRequest) { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + return authFailedResponse; + } + + const searchParams = request.nextUrl.searchParams; + const userId = searchParams.get('userId'); + const startDate = searchParams.get('startDate'); + const endDate = searchParams.get('endDate'); + + if (!userId || !startDate || !endDate) { + return new Response(JSON.stringify({ error: 'userId, startDate, and endDate are required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const conditions = [ + eq(api_request_log.kilo_user_id, userId), + gte(api_request_log.created_at, new Date(startDate).toISOString()), + lte(api_request_log.created_at, new Date(endDate + 'T23:59:59.999Z').toISOString()), + ]; + + const rows = await db + .select() + .from(api_request_log) + .where(and(...conditions)) + .orderBy(asc(api_request_log.created_at)); + + if (rows.length === 0) { + return new Response(JSON.stringify({ error: 'No records found for the given criteria' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const passthrough = new PassThrough(); + const archive = archiver('zip', { zlib: { level: 6 } }); + + archive.pipe(passthrough); + + for (const row of rows) { + const ts = formatTimestamp(row.created_at); + const id = String(row.id); + + const requestExt = isJson(row.request) ? 'json' : 'txt'; + const requestContent = tryFormatJson(row.request); + if (requestContent) { + archive.append(requestContent, { name: `${ts}_${id}_request.${requestExt}` }); + } + + const responseExt = isJson(row.response) ? 'json' : 'txt'; + const responseContent = tryFormatJson(row.response); + if (responseContent) { + archive.append(responseContent, { name: `${ts}_${id}_response.${responseExt}` }); + } + } + + archive.finalize(); + + const webStream = new ReadableStream({ + start(controller) { + passthrough.on('data', (chunk: Buffer) => controller.enqueue(chunk)); + passthrough.on('end', () => controller.close()); + passthrough.on('error', (err) => controller.error(err)); + }, + }); + + const filename = `api-request-log_${userId}_${startDate}_${endDate}.zip`; + + return new Response(webStream, { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${filename}"`, + }, + }); +} diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 01e36ddec..9b611a732 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -183,6 +183,11 @@ const analyticsObservabilityItems: MenuItem[] = [ url: '/admin/alerting-ttfb', icon: () => , }, + { + title: () => 'API Request Log', + url: '/admin/api-request-log', + icon: () => , + }, ]; const menuSections: MenuSection[] = [ diff --git a/src/app/api/cron/cleanup-api-request-log/route.ts b/src/app/api/cron/cleanup-api-request-log/route.ts index b9472b020..3ed521ee5 100644 --- a/src/app/api/cron/cleanup-api-request-log/route.ts +++ b/src/app/api/cron/cleanup-api-request-log/route.ts @@ -18,7 +18,7 @@ export async function GET(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const cutoffDate = getDaysAgo(7).toISOString(); + const cutoffDate = getDaysAgo(30).toISOString(); const result = await db.delete(api_request_log).where(lt(api_request_log.created_at, cutoffDate)); await fetch(BETTERSTACK_HEARTBEAT_URL); diff --git a/src/app/api/openrouter/[...path]/route.ts b/src/app/api/openrouter/[...path]/route.ts index 1994fbb1d..0548f5c4c 100644 --- a/src/app/api/openrouter/[...path]/route.ts +++ b/src/app/api/openrouter/[...path]/route.ts @@ -65,6 +65,7 @@ import { checkPromotionLimit, } from '@/lib/free-model-rate-limiter'; import { PROMOTION_MAX_REQUESTS, PROMOTION_WINDOW_HOURS } from '@/lib/constants'; +import { handleRequestLogging } from '@/lib/handleRequestLogging'; import { classifyAbuse } from '@/lib/abuse-service'; import { emitApiMetricsForResponse, @@ -575,7 +576,6 @@ export async function POST(request: NextRequest): Promise Date: Mon, 30 Mar 2026 20:21:25 +0000 Subject: [PATCH 2/2] fix(api-request-log): address review feedback - validate dates, use direct URL download - Validate date params before building queries to return 400 instead of 500 - Use window.location.href for download to preserve end-to-end streaming - Add void to archive.finalize() to satisfy lint - Run oxfmt formatting --- src/app/admin/api-request-log/page.tsx | 45 +++++-------------- .../api/api-request-log/download/route.ts | 45 ++++++++++++------- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/app/admin/api-request-log/page.tsx b/src/app/admin/api-request-log/page.tsx index 0e6ba40ad..dc6da9333 100644 --- a/src/app/admin/api-request-log/page.tsx +++ b/src/app/admin/api-request-log/page.tsx @@ -17,10 +17,9 @@ export default function ApiRequestLogPage() { const [userId, setUserId] = useState(''); const [startDate, setStartDate] = useState(weekAgo); const [endDate, setEndDate] = useState(today); - const [downloading, setDownloading] = useState(false); const [error, setError] = useState(null); - async function handleDownload() { + function handleDownload() { if (!userId.trim()) { setError('User ID is required'); return; @@ -31,39 +30,15 @@ export default function ApiRequestLogPage() { } setError(null); - setDownloading(true); - try { - const params = new URLSearchParams({ - userId: userId.trim(), - startDate, - endDate, - }); + const params = new URLSearchParams({ + userId: userId.trim(), + startDate, + endDate, + }); - const response = await fetch(`/admin/api/api-request-log/download?${params}`); - - if (!response.ok) { - const body = await response.json().catch(() => null); - setError(body?.error ?? `Download failed (${response.status})`); - return; - } - - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = - response.headers.get('Content-Disposition')?.match(/filename="(.+)"/)?.[1] ?? - 'api-request-log.zip'; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } catch (e) { - setError(e instanceof Error ? e.message : 'Download failed'); - } finally { - setDownloading(false); - } + // Navigate directly to preserve server-side streaming + window.location.href = `/admin/api/api-request-log/download?${params}`; } return ( @@ -109,9 +84,9 @@ export default function ApiRequestLogPage() { {error &&

{error}

} - diff --git a/src/app/admin/api/api-request-log/download/route.ts b/src/app/admin/api/api-request-log/download/route.ts index 5717e7dca..4567f1bdc 100644 --- a/src/app/admin/api/api-request-log/download/route.ts +++ b/src/app/admin/api/api-request-log/download/route.ts @@ -43,6 +43,19 @@ function isJson(value: unknown): boolean { return false; } +function parseDate(value: string): Date | null { + const d = new Date(value); + if (isNaN(d.getTime())) return null; + return d; +} + +function jsonError(message: string, status: number) { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + export async function GET(request: NextRequest) { const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); if (authFailedResponse) { @@ -55,29 +68,29 @@ export async function GET(request: NextRequest) { const endDate = searchParams.get('endDate'); if (!userId || !startDate || !endDate) { - return new Response(JSON.stringify({ error: 'userId, startDate, and endDate are required' }), { - status: 400, - headers: { 'Content-Type': 'application/json' }, - }); + return jsonError('userId, startDate, and endDate are required', 400); } - const conditions = [ - eq(api_request_log.kilo_user_id, userId), - gte(api_request_log.created_at, new Date(startDate).toISOString()), - lte(api_request_log.created_at, new Date(endDate + 'T23:59:59.999Z').toISOString()), - ]; + const parsedStart = parseDate(startDate); + const parsedEnd = parseDate(endDate + 'T23:59:59.999Z'); + if (!parsedStart || !parsedEnd) { + return jsonError('Invalid date format. Use YYYY-MM-DD.', 400); + } const rows = await db .select() .from(api_request_log) - .where(and(...conditions)) + .where( + and( + eq(api_request_log.kilo_user_id, userId), + gte(api_request_log.created_at, parsedStart.toISOString()), + lte(api_request_log.created_at, parsedEnd.toISOString()) + ) + ) .orderBy(asc(api_request_log.created_at)); if (rows.length === 0) { - return new Response(JSON.stringify({ error: 'No records found for the given criteria' }), { - status: 404, - headers: { 'Content-Type': 'application/json' }, - }); + return jsonError('No records found for the given criteria', 404); } const passthrough = new PassThrough(); @@ -102,13 +115,13 @@ export async function GET(request: NextRequest) { } } - archive.finalize(); + void archive.finalize(); const webStream = new ReadableStream({ start(controller) { passthrough.on('data', (chunk: Buffer) => controller.enqueue(chunk)); passthrough.on('end', () => controller.close()); - passthrough.on('error', (err) => controller.error(err)); + passthrough.on('error', err => controller.error(err)); }, });