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
38 changes: 38 additions & 0 deletions docs/design/openai-log-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# OpenAI Log Retention

## Context

When OpenAI-compatible API logging is enabled, Qwen Code writes one JSON file per request and response. Heavy use can create hundreds of thousands of files and consume tens of gigabytes because the log directory has no retention policy.

Historical files have one in-tree reader, which searches only recent logs for the current session. Removing sufficiently old writer-owned files does not affect session restore or active requests.

## Design

Interactive sessions register an OpenAI log cleaner in the existing background housekeeping pipeline. The cleaner runs at most once per resolved log directory per day and uses a dedicated retention setting with a seven-day default. A zero value uses the housekeeping minimum of approximately one hour.

Deletion is restricted to the exact filename shape emitted by `OpenAILogger`: a UTC timestamp, an eight-character hexadecimal ID, and an optional sanitized diagnostic suffix. Prefix-only lookalikes are never deleted. The cleaner streams directory entries and processes at most 20 files concurrently so the first sweep of a very large directory does not retain the full listing in memory.

The UTC date in a valid filename avoids one `stat` call per file. Files on the cutoff day use mtime for sub-day precision. A missing directory is a successful no-op; a root scan failure aborts the throttled task so it does not write a success marker. Individual file failures are counted and do not stop later batches, while a file that disappears during deletion is benign.

The first-pass scheduler checks both the global file-history marker and the marker for the resolved OpenAI log directory. A missing or stale OpenAI marker selects the one-minute catch-up delay even when file-history cleanup ran recently.

## Configuration ownership

The default log directory is relative to the workspace, so its merged workspace retention setting and its directory have the same owner.

A custom log directory can be shared by multiple workspaces, but its flat files do not record workspace ownership. Applying different workspace retention values would make deletion depend on which workspace starts first. Custom directories therefore use only user- or system-scoped retention. If a trusted workspace supplies the effective retention value and no system override owns the policy, cleanup is skipped instead of choosing a destructive policy silently.

## Scope

The housekeeping scheduler starts only for interactive sessions. Headless CLI and SDK-only processes can still write logs without starting a sweep; the setting documentation states this limitation. Moving cleanup onto the write path remains a separate follow-up because it changes core logging behavior and process coordination.

## Alternatives considered

- Reusing the 30-day file-history setting would retain too much high-volume API data.
- Matching every `openai-*.json` file is unsafe in project-local and user-selected directories.
- A per-workspace marker for one shared directory still lets the shortest policy delete files owned by another workspace.
- Adding workspace identity to filenames or file contents would require a core logging format migration and would not establish ownership for existing logs.

## Verification

Focused tests cover writer-format recognition, preservation of lookalikes, cutoff boundaries, custom-directory policy ownership, default and zero retention, oversized retention values, per-directory throttling, settings fallback, root scan failures, and catch-up delay selection. The streaming loop is inspected directly to confirm that only one bounded batch is retained.
1 change: 1 addition & 0 deletions docs/users/configuration/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ Settings are organized into categories. Most settings should be placed within th
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
| `model.enableOpenAILogging` | boolean | Enables logging of OpenAI API calls for debugging and analysis. When enabled, API requests and responses are logged to JSON files. | `false` |
| `model.openAILoggingDir` | string | Custom directory path for OpenAI API logs. If not specified, defaults to `logs/openai` in the current working directory. Supports absolute paths, relative paths (resolved from current working directory), and `~` expansion (home directory). | `undefined` |
| `model.openAILogRetentionDays` | number | Days to retain OpenAI API log files written when `model.enableOpenAILogging` is on. Log files older than this are removed by an interactive-session background housekeeping pass that runs at most once per day; headless and SDK-only use does not start the pass. `0` = minimum retention (~1 hour). For a custom `model.openAILoggingDir`, configure retention at user or system scope; workspace-scoped retention is skipped because one custom directory can be shared by multiple workspaces. Changes take effect after restart. | `7` |

**Example model.generationConfig:**

Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
import type { CustomTheme } from '../ui/themes/theme.js';
import { getLanguageSettingsOptions } from '../i18n/languages.js';

export const DEFAULT_OPENAI_LOG_RETENTION_DAYS = 7;

export type SettingsType =
| 'boolean'
| 'string'
Expand Down Expand Up @@ -1590,6 +1592,19 @@ const SETTINGS_SCHEMA = {
'Custom directory path for OpenAI API logs. If not specified, defaults to logs/openai in the current working directory.',
showInDialog: false,
},
openAILogRetentionDays: {
type: 'number',
label: 'OpenAI Log Retention (days)',
category: 'Model',
// LoadedSettings._merged is cached without verified setValue→recompute
// paths in all UI flows (same rationale as general.cleanupPeriodDays).
requiresRestart: true,
default: DEFAULT_OPENAI_LOG_RETENTION_DAYS,
minimum: 0,
description:
'Number of days to retain OpenAI API log files written when enableOpenAILogging is on. Log files older than this are removed by an interactive-session background housekeeping pass that runs at most once per day. Set to 0 for minimum retention (~1 hour). For a custom openAILoggingDir, configure this at user or system scope; workspace-scoped retention is skipped because one directory can be shared by multiple workspaces.',
showInDialog: false,
},
generationConfig: {
type: 'object',
label: 'Generation Configuration',
Expand Down
149 changes: 149 additions & 0 deletions packages/cli/src/utils/housekeeping/cleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { OpenAILogger } from '@qwen-code/qwen-code-core';
import {
cleanupOldFileHistoryBackups,
cleanupOldOpenAILogs,
cleanupOldSubagentTranscripts,
getCutoffDate,
} from './cleanup.js';
Expand Down Expand Up @@ -62,6 +64,12 @@ describe('getCutoffDate', () => {
expect(cutoff.getTime()).toBeLessThanOrEqual(after - MS_PER_HOUR);
expect(cutoff.getTime()).toBeLessThanOrEqual(after); // NOT in future
});

it('keeps oversized retention values within the valid Date range', () => {
const cutoff = getCutoffDate(Number.MAX_VALUE);
expect(cutoff.getTime()).toBe(-8_640_000_000_000_000);
expect(() => cutoff.toISOString()).not.toThrow();
});
});

describe('cleanupOldFileHistoryBackups', () => {
Expand Down Expand Up @@ -253,3 +261,144 @@ describe('cleanupOldSubagentTranscripts', () => {
expect(fs.readdirSync(subagentsRoot)).toEqual([]);
});
});

describe('cleanupOldOpenAILogs', () => {
let logDir: string;
let cutoff: Date;

beforeEach(() => {
logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-openai-logs-test-'));
cutoff = new Date(Date.now() - 7 * MS_PER_DAY);
});

afterEach(() => {
fs.rmSync(logDir, { recursive: true, force: true });
});

function mkLog(name: string, mtime: Date): string {
const p = path.join(logDir, name);
fs.writeFileSync(p, '{}');
fs.utimesSync(p, mtime, mtime);
return p;
}

function openAILogName(
timestamp: Date,
id = 'a1b2c3d4',
suffix?: string,
): string {
return `openai-${timestamp.toISOString().replace(/:/g, '-')}-${id}${suffix ? `-${suffix}` : ''}.json`;
}

it('returns zero result when the log dir does not exist', async () => {
const r = await cleanupOldOpenAILogs({
logDir: path.join(logDir, 'nope'),
cutoffDate: cutoff,
});
expect(r).toEqual({ removed: 0, errors: 0 });
});

it('removes logs whose filename date is older than the cutoff, even with a fresh mtime', async () => {
// Filename date is authoritative when parseable: the mtime may have been
// touched long after the log was written.
const old = mkLog(
openAILogName(new Date(Date.now() - 30 * MS_PER_DAY)),
new Date(),
);
const r = await cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff });
expect(r).toEqual({ removed: 1, errors: 0 });
expect(fs.existsSync(old)).toBe(false);
// The project-local log dir itself is never removed.
expect(fs.existsSync(logDir)).toBe(true);
});

it('keeps logs whose filename date is newer than the cutoff', async () => {
const recent = new Date(Date.now() - 1 * MS_PER_DAY);
const name = openAILogName(recent, 'b2c3d4e5', 'side-query-session-title');
const fresh = mkLog(name, recent);
const r = await cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff });
expect(r).toEqual({ removed: 0, errors: 0 });
expect(fs.existsSync(fresh)).toBe(true);
});

it('preserves old openai-prefixed JSON files not emitted by OpenAILogger', async () => {
const oldPrefixedFile = mkLog(
'openai-not-a-date.json',
new Date(Date.now() - 30 * MS_PER_DAY),
);
const missingId = mkLog(
'openai-2026-06-01T10-00-00.000Z.json',
new Date(Date.now() - 30 * MS_PER_DAY),
);
const r = await cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff });
expect(r).toEqual({ removed: 0, errors: 0 });
expect(fs.existsSync(oldPrefixedFile)).toBe(true);
expect(fs.existsSync(missingId)).toBe(true);
});

it('recognizes the current OpenAILogger filename contract', async () => {
const logger = new OpenAILogger(logDir);
const generated = await logger.logInteraction(
{ model: 'test-model' },
{ choices: [] },
undefined,
'side-query:session-title',
);

const r = await cleanupOldOpenAILogs({
logDir,
cutoffDate: new Date(Date.now() + MS_PER_DAY),
});
expect(r).toEqual({ removed: 1, errors: 0 });
expect(fs.existsSync(generated)).toBe(false);
});

it('uses mtime to disambiguate files dated exactly on the cutoff day', async () => {
const cutoffDay = cutoff.toISOString().slice(0, 10);
const olderThanCutoff = mkLog(
`openai-${cutoffDay}T00-00-00.000Z-a1b2c3d4.json`,
new Date(cutoff.getTime() - MS_PER_HOUR),
);
const newerThanCutoff = mkLog(
`openai-${cutoffDay}T23-59-59.999Z-b2c3d4e5-subagent-Explore-g2tss0.json`,
new Date(cutoff.getTime() + MS_PER_HOUR),
);
const r = await cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff });
expect(r).toEqual({ removed: 1, errors: 0 });
expect(fs.existsSync(olderThanCutoff)).toBe(false);
expect(fs.existsSync(newerThanCutoff)).toBe(true);
});

it('ignores non-matching files and directories', async () => {
const note = mkLog('notes.txt', new Date(Date.now() - 60 * MS_PER_DAY));
const otherLog = mkLog(
'openai-logs.txt',
new Date(Date.now() - 60 * MS_PER_DAY),
);
const dirWithMatchingName = path.join(
logDir,
'openai-2026-06-01T00-00-00.000Z-a1b2c3d4.json',
);
fs.mkdirSync(dirWithMatchingName);

const r = await cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff });
expect(r).toEqual({ removed: 0, errors: 0 });
expect(fs.existsSync(note)).toBe(true);
expect(fs.existsSync(otherLog)).toBe(true);
expect(fs.existsSync(dirWithMatchingName)).toBe(true);
});

it.skipIf(process.platform === 'win32')(
'rejects when the log directory cannot be scanned',
async () => {
fs.chmodSync(logDir, 0o000);
try {
await expect(
cleanupOldOpenAILogs({ logDir, cutoffDate: cutoff }),
).rejects.toMatchObject({ code: 'EACCES' });
} finally {
fs.chmodSync(logDir, 0o700);
}
},
);
});
Loading
Loading