Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ R2_ACCOUNT_ID=mock-test-account-id
R2_ACCESS_KEY_ID=mock-test-access-key
R2_SECRET_ACCESS_KEY=mock-test-secret-key
R2_CLI_SESSIONS_BUCKET_NAME=test-bucket
R2_API_REQUEST_LOG_BUCKET_NAME=test-api-request-log
R2_API_REQUEST_LOG_ACCESS_KEY_ID=mock-test-api-request-log-access-key
R2_API_REQUEST_LOG_SECRET_ACCESS_KEY=mock-test-api-request-log-secret-key
# ============================================================================
# AI / Model Inference (Required for AI features)
# ============================================================================
Expand Down
3 changes: 3 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra
- `R2_ACCESS_KEY_ID` - R2 access key ID for CLI session storage. `[SECRET]`
- `R2_SECRET_ACCESS_KEY` - R2 secret access key for CLI session storage. `[SECRET]`
- `R2_CLI_SESSIONS_BUCKET_NAME` - R2 bucket name for CLI session blobs. [SERVER]
- `R2_API_REQUEST_LOG_BUCKET_NAME` - R2 bucket for `api_request_log` request and response bodies. [SERVER]
- `R2_API_REQUEST_LOG_ACCESS_KEY_ID` - R2 access key ID used only for the `api_request_log` bucket; uses the `R2_ACCOUNT_ID` account. `[SECRET]`
- `R2_API_REQUEST_LOG_SECRET_ACCESS_KEY` - R2 secret access key paired with `R2_API_REQUEST_LOG_ACCESS_KEY_ID`. `[SECRET]`

## Services

Expand Down
3 changes: 3 additions & 0 deletions apps/web/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ R2_ACCOUNT_ID=mock-test-account-id
R2_ACCESS_KEY_ID=mock-test-access-key
R2_SECRET_ACCESS_KEY=mock-test-secret-key
R2_CLI_SESSIONS_BUCKET_NAME=test-bucket
R2_API_REQUEST_LOG_BUCKET_NAME=test-api-request-log
R2_API_REQUEST_LOG_ACCESS_KEY_ID=mock-test-api-request-log-access-key
R2_API_REQUEST_LOG_SECRET_ACCESS_KEY=mock-test-api-request-log-secret-key
CODE_REVIEW_WORKER_URL=mock-url''
CODE_REVIEW_WORKER_AUTH_TOKEN='mock-key'
AUTO_TRIAGE_URL='mock-url'
Expand Down
101 changes: 90 additions & 11 deletions apps/web/src/app/admin/api/api-request-log/download/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { NextRequest } from 'next/server';
import { randomBytes } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { strFromU8, unzipSync } from 'fflate';
import { api_request_log } from '@kilocode/db/schema';
import { db } from '@/lib/drizzle';
import { getUserFromAuth } from '@/lib/user/server';
import type { FakeR2ClientModule } from '@/tests/helpers/fake-r2.helper';
import { defineTestUser } from '@/tests/helpers/user.helper';
import { GET } from './route';

Expand All @@ -17,10 +19,20 @@ jest.mock('@/lib/user/server', () => ({
getUserFromAuth: jest.fn(),
}));

jest.mock('@/lib/r2/client', () =>
jest
.requireActual<{
createFakeR2ClientModule: () => FakeR2ClientModule;
}>('@/tests/helpers/fake-r2.helper')
.createFakeR2ClientModule()
);

const { fakeR2 } = jest.requireMock<FakeR2ClientModule>('@/lib/r2/client');
const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const TEST_USER_ID = 'api-request-log-download-test-user';
const TEST_MODEL = 'poolside/laguna-s-2.1:free';
const BATCH_SIZE = 25;
const BUCKET = 'test-api-request-log';

function createRequest() {
const params = new URLSearchParams({
Expand All @@ -38,34 +50,50 @@ function readEntry(entries: Record<string, Uint8Array>, suffix: string): string
return strFromU8(entries[name]);
}

async function downloadEntries() {
const response = await GET(createRequest());
expect(response.status).toBe(200);
return unzipSync(new Uint8Array(await response.arrayBuffer()));
}

const baseRow = {
created_at: '2026-08-01T12:00:00.000Z',
kilo_user_id: TEST_USER_ID,
provider: 'test-provider',
model: TEST_MODEL,
};

describe('GET /admin/api/api-request-log/download', () => {
beforeEach(() => {
fakeR2.objects.clear();
mockedGetUserFromAuth.mockResolvedValue({
user: defineTestUser({ is_admin: true }),
authFailedResponse: null,
});
});

afterEach(async () => {
jest.restoreAllMocks();
await db.delete(api_request_log).where(eq(api_request_log.kilo_user_id, TEST_USER_ID));
});

it('streams a complete ZIP across backpressured DB batches', async () => {
it('streams a complete ZIP of R2-backed bodies across backpressured DB batches', async () => {
// The first batch must exceed both the Node and web stream queues. This
// keeps page two blocked until the test starts consuming the response.
const payload = randomBytes(128 * 1024).toString('base64');
const values = Array.from({ length: BATCH_SIZE + 1 }, (_, index) => {
const request_r2_key = `2026-08-01/row-${index}/request.json`;
const response_r2_key = `2026-08-01/row-${index}/response.txt`;
fakeR2.objects.set(`${BUCKET}/${request_r2_key}`, JSON.stringify({ index }));
fakeR2.objects.set(
`${BUCKET}/${response_r2_key}`,
JSON.stringify({ output: index, payload })
);
return { ...baseRow, request_r2_key, response_r2_key };
});
const rows = await db
.insert(api_request_log)
.values(
Array.from({ length: BATCH_SIZE + 1 }, (_, index) => ({
created_at: '2026-08-01T12:00:00.000Z',
kilo_user_id: TEST_USER_ID,
provider: 'test-provider',
model: TEST_MODEL,
request: { index },
response: JSON.stringify({ output: index, payload }),
}))
)
.values(values)
.returning({ id: api_request_log.id });

const response = await GET(createRequest());
Expand All @@ -89,4 +117,55 @@ describe('GET /admin/api/api-request-log/download', () => {
payload,
});
});

it('exports legacy rows whose bodies are stored inline', async () => {
const [row] = await db
.insert(api_request_log)
.values({ ...baseRow, request: { legacy: true }, response: 'data: legacy\n\n' })
.returning({ id: api_request_log.id });

const entries = await downloadEntries();

expect(Object.keys(entries)).toHaveLength(2);
expect(readEntry(entries, `_${row.id}_request.json`)).toBe(
JSON.stringify({ legacy: true }, null, 2)
);
expect(readEntry(entries, `_${row.id}_response.txt`)).toBe('data: legacy\n\n');
});

it('skips missing R2 objects and records R2 read failures without aborting the export', async () => {
const [missing, failing] = await db
.insert(api_request_log)
.values([
{
...baseRow,
request_r2_key: 'missing/request.json',
response_r2_key: 'missing/response.txt',
},
{
...baseRow,
request_r2_key: 'failing/request.json',
response_r2_key: null,
error: { response_body_read_error: 'upstream disconnected' },
},
])
.returning({ id: api_request_log.id });
const send = fakeR2.send.bind(fakeR2);
jest.spyOn(fakeR2, 'send').mockImplementation(async command => {
if (command instanceof GetObjectCommand && command.input.Key === 'failing/request.json') {
throw new Error('R2 unavailable');
}
return send(command);
});

const entries = await downloadEntries();

expect(Object.keys(entries).filter(name => name.includes(`_${missing.id}_`))).toEqual([]);
expect(readEntry(entries, `_${failing.id}_request_load-error.txt`)).toBe(
'Failed to load failing/request.json from R2: Error: R2 unavailable'
);
expect(JSON.parse(readEntry(entries, `_${failing.id}_error.json`))).toEqual({
response_body_read_error: 'upstream disconnected',
});
});
});
50 changes: 37 additions & 13 deletions apps/web/src/app/admin/api/api-request-log/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { api_request_log } from '@kilocode/db/schema';
import { and, gte, lte, eq, asc, gt, count, or, isNotNull, type SQL } from 'drizzle-orm';
import archiver from 'archiver';
import { Readable } from 'node:stream';
import { getApiRequestLogBlob } from '@/lib/r2/api-request-log';

// Downloading all logs for a heavy user can take a while. Without a raised
// maxDuration the Vercel function was killed mid-stream, producing a ZIP
Expand Down Expand Up @@ -49,6 +50,19 @@ function isJson(value: unknown): boolean {
return false;
}

type LoadedBody = { value: unknown } | { loadError: string };

async function loadBody(key: string | null, legacyInlineValue: unknown): Promise<LoadedBody> {
if (key === null) {
return { value: legacyInlineValue };
}
try {
return { value: await getApiRequestLogBlob(key) };
} catch (error) {
return { loadError: `Failed to load ${key} from R2: ${String(error)}` };
}
}

function parseDate(value: string): Date | null {
const d = new Date(value);
if (isNaN(d.getTime())) return null;
Expand Down Expand Up @@ -182,22 +196,32 @@ export async function GET(request: NextRequest) {

if (rows.length === 0) break;

for (const row of rows) {
const bodies = await Promise.all(
rows.map(async row => {
const [request, response] = await Promise.all([
loadBody(row.request_r2_key, row.request),
loadBody(row.response_r2_key, row.response),
]);
return { request, response };
})
);

for (const [index, row] of rows.entries()) {
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) {
totalAppendedEntries += 1;
archive.append(requestContent, { name: `${ts}_${id}_request.${requestExt}` });
}

const responseExt = isJson(row.response) ? 'json' : 'txt';
const responseContent = tryFormatJson(row.response);
if (responseContent) {
totalAppendedEntries += 1;
archive.append(responseContent, { name: `${ts}_${id}_response.${responseExt}` });
for (const [kind, body] of Object.entries(bodies[index])) {
if ('loadError' in body) {
totalAppendedEntries += 1;
archive.append(body.loadError, { name: `${ts}_${id}_${kind}_load-error.txt` });
continue;
}
const ext = isJson(body.value) ? 'json' : 'txt';
const content = tryFormatJson(body.value);
if (content) {
totalAppendedEntries += 1;
archive.append(content, { name: `${ts}_${id}_${kind}.${ext}` });
}
}

if (row.error !== null && row.error !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ describe('GET /api/cron/cleanup-api-request-log', () => {
});
});

it('deletes records older than seven days and preserves recent records', async () => {
it('deletes records older than thirty days and preserves recent records', async () => {
await insertApiRequestLogRecord(daysAgo(45));
await insertApiRequestLogRecord(daysAgo(8));
const recent = await insertApiRequestLogRecord(daysAgo(6));
await insertApiRequestLogRecord(daysAgo(31));
const recent = await insertApiRequestLogRecord(daysAgo(29));

const response = await GET(makeRequest({ authorization: 'Bearer cron-secret' }));

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/api/cron/cleanup-api-request-log/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { api_request_log } from '@kilocode/db/schema';
import { asc, inArray, lt } from 'drizzle-orm';
import { CRON_SECRET } from '@/lib/config.server';

const RETENTION_DAYS = 7;
const RETENTION_DAYS = 30;
const BATCH_SIZE = 10_000;

function getDaysAgo(days: number) {
Expand Down
Loading
Loading