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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
Status: implemented
Date: 2026-10-10
---
# 本地轨迹默认不限数量,改由 10GB 体积兜底(与会话保留同口径)

## 决定了什么

- `TraceCollector.pruneOldSessions()` 默认不再按数量删(旧默认 `maxSessionsRetained: 100`)。
防盘满改为 `trajectories/sessions/` 总体积上限,取 `sessionRetention.maxTotalSize`(默认 10GB),
`sessionRetention.enabled: false` 时不按体积删。`trace.maxSessionsRetained` 保留,显式配置仍生效。
- 淘汰选择抽成纯函数 `packages/core/src/trace/retention.ts`:已上传优先、最旧优先;
活跃进程正在写的会话(含 resume 的 logicalSessionId)与 `minRetention`(默认 1d)窗口内的目录永不删。
- 同步:`init-helpers.ts` 把 `sessionRetention` 接进 collector;`disk-usage.ts` 保留策略登记表、
`config.ts` 字段注释、`website/team/observability.md`「本地保留多少」、`website/ref/settings.md`。

## 放弃了什么(以及为什么不选)

- **只把默认 100 调到 1000/3000**:仍是拿数量近似体积,问题只是推迟;而且和 PR #218 定下的
「会话按体积兜底」口径不一致,两套旋钮各管一半。
- **另起一个 `trace.maxTotalSize`**:会话清理已经把「会话 + 同 id 轨迹」计入 10GB,
再起一个数就是两处事实源。轨迹单独的那道兜底复用同一个值。
- 此前 `session-index.ts` / `collector.ts` 注释里「不要调大上限」的论证没有被推翻其结论
(指标长留靠索引,不靠原始轨迹),被推翻的是它的前提「原始轨迹 ≈45MB/会话」,
以及「原始轨迹只为指标服务」——它还是开发者调试的唯一材料。

## 拿什么证明它生效了

- 实测前提:开发者本机 `~/.sid-code/trajectories/sessions/` 恰好 100 个目录(已顶上限,最早 2026-09-11),
合计 37MB,单个 p50 24KB / p95 1.3MB / 最大 3.2MB;一天 24~36 个会话 ⇒ 100 个只够 2~4 周。
- `packages/core/tests/trace/retention.test.ts`:反向门禁「150 个旧目录默认全部保留」;
变异自证:把默认值改回 `?? 100` 后该用例红(8 pass / 1 fail),还原后全绿。
- `bun test` trace 相关 5 个文件 203 pass / 0 fail(含既有 LRU 用例与 P0-2 端到端)。
2 changes: 1 addition & 1 deletion packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ export interface TraceConfig {
enabled?: boolean;
/** 本地输出目录(默认 ~/.sid-code/trajectories) */
outputDir?: string;
/** 本地最大保留会话数(默认 100,超过自动清理最旧的) */
/** 本地最大保留会话数(默认不限;防盘满靠 sessionRetention.maxTotalSize 体积兜底) */
maxSessionsRetained?: number;
/**
* 是否把**请求/响应原文**写进 `raw.jsonl`(默认 `true`,保持既有行为)。
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/config/disk-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const RETENTION: Record<string, { text: string; days: number | null }> = {
days: 1,
},
trajectories: {
text: "LRU 保留最近 100 会话 + 随会话保留期过期(默认 365 天)",
text: "默认不限数量;随会话保留期过期(默认 365 天),总量超 sessionRetention.maxTotalSize(10GB)时删最旧",
days: 365,
},
checkpoints: { text: "30 天过期 + 200MB LRU(启动期兜底触发)", days: 30 },
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/query/init-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,34 @@ export async function initTraceCollector(
// 真正的终态就永远不会再被补传。
}

// 轨迹与会话共用一个体积上限 / 最小保留窗口(session/retention.ts 是单一事实源);
// 关闭自动清理时轨迹也不按体积删(maxTotalBytes=0)。
const { resolveRetentionSettings, parseRetentionSize, parseRetentionPeriod } =
await import("../session/retention.ts");
const retention = resolveRetentionSettings(config.sessionRetention, config.cleanupPeriodDays);
let maxTotalBytes: number | undefined;
let minRetentionMs: number | undefined;
if (!retention.enabled) {
maxTotalBytes = 0;
} else {
// 解析失败 → 留 undefined 交给 collector 用默认值,不让写错的配置变成「全删」
try {
maxTotalBytes = parseRetentionSize(retention.maxTotalSize!);
} catch {
log.warn("TRACE", `sessionRetention.maxTotalSize 格式无效,轨迹按默认上限清理`);
}
try {
minRetentionMs = parseRetentionPeriod(retention.minRetention!);
} catch {
/* 用默认 */
}
}
const collector = new TraceCollector(
{
outputDir: traceConfig.outputDir,
maxSessionsRetained: traceConfig.maxSessionsRetained,
maxTotalBytes,
minRetentionMs,
// 不传(undefined)时由 collector 侧解析 env 兜底 SID_CODE_TRACE_NO_RAW,
// 所以这里原样透传而不是先 `?? true` —— 提前定死会把 env 通道堵掉。
recordRawPayloads: traceConfig.recordRawPayloads,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/trace/backfill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* ## 判据为什么用 `.uploaded` 而不是重试队列
*
* 重试队列(`.upload_queue.jsonl`)本身不可靠:条目指向的会话目录会被 LRU
* (`maxSessionsRetained` 默认 100)轮转删掉,剩下一堆指向空地址的门票。
* (体积兜底 / 显式 `maxSessionsRetained`)轮转删掉,剩下一堆指向空地址的门票。
* 而「目录在、`session.traj` 在、`.uploaded` 缺」这个判据是**自洽**的:
* 它只依赖磁盘现状,不依赖任何一个可能已经失真的旁路记录。
*
Expand Down
119 changes: 89 additions & 30 deletions packages/core/src/trace/collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ import { TraceWriter, type RawJsonlEntry, type HookEvent } from "./writer.ts";
import { buildTrajectory, type RequestResponsePair, type TraceMetadata } from "./builder.ts";
import { buildDigest, resolvePaths, type SessionLevelMetrics } from "./digest.ts";
import { upsertSessionIndex, buildSessionIndexEntry } from "./session-index.ts";
import { selectTraceDirsToPrune, type TraceDirEntry } from "./retention.ts";
import {
DEFAULT_SESSION_MAX_TOTAL_SIZE,
DEFAULT_SESSION_MIN_RETENTION,
parseRetentionPeriod,
parseRetentionSize,
} from "../session/retention.ts";
import { listActiveSessions } from "../session/concurrent.ts";
import {
setPermissionDecisionObserver,
type PermissionDecisionEvent,
Expand Down Expand Up @@ -99,8 +107,18 @@ export interface TraceUploaderInterface {
export interface CollectorOptions {
/** 本地输出目录(默认 ~/.sid-code/trajectories) */
outputDir?: string;
/** 本地最大保留会话数(默认 100) */
/**
* 本地最大保留会话数。默认不限(undefined)—— 防盘满靠 {@link maxTotalBytes},
* 不靠数量(理由见 `trace/retention.ts`)。显式配置时仍生效。
*/
maxSessionsRetained?: number;
/**
* 轨迹目录总体积上限(字节)。默认取 `sessionRetention.maxTotalSize` 的默认值(10GB);
* 传 0 / 负数 = 不按体积删(`sessionRetention.enabled: false` 时由调用方传 0)。
*/
maxTotalBytes?: number;
/** 更新时间在此窗口内的目录不删(毫秒,默认 1 天,对齐 `sessionRetention.minRetention`) */
minRetentionMs?: number;
/**
* 是否把请求/响应原文写进 `raw.jsonl`(默认 true)。
* 不传时由 {@link resolveRecordRawPayloads} 解析 env 兜底。
Expand Down Expand Up @@ -240,8 +258,11 @@ export class TraceCollector {
/** 见 CollectorOptions.autoUpload */
private readonly autoUpload: boolean;
private readonly outputDir: string;
/** 本地最大保留会话数(LRU 清理用,默认 100) */
private readonly maxSessionsRetained: number;
/** 本地最大保留会话数(默认不限) */
private readonly maxSessionsRetained: number | undefined;
/** 轨迹目录总体积上限(字节);undefined = 不按体积删 */
private readonly maxTotalBytes: number | undefined;
private readonly minRetentionMs: number;
/**
* SessionEnd 里等待上传的时间预算(毫秒)。0 = 不在退出路径等上传。
*
Expand Down Expand Up @@ -375,11 +396,16 @@ export class TraceCollector {

constructor(options: CollectorOptions = {}, uploader: TraceUploaderInterface | null = null) {
this.outputDir = options.outputDir ?? sidPaths.trajectories();
this.maxSessionsRetained = options.maxSessionsRetained ?? 100;
this.maxSessionsRetained = options.maxSessionsRetained;
const maxTotalBytes =
options.maxTotalBytes ?? parseRetentionSize(DEFAULT_SESSION_MAX_TOTAL_SIZE);
this.maxTotalBytes = maxTotalBytes > 0 ? maxTotalBytes : undefined;
this.minRetentionMs =
options.minRetentionMs ?? parseRetentionPeriod(DEFAULT_SESSION_MIN_RETENTION);
this.recordRawPayloads = resolveRecordRawPayloads(options.recordRawPayloads);
this.uploader = uploader;
this.autoUpload = options.autoUpload !== false;
// 启动时做一次 LRU 清理,回收已上传/旧会话目录,防止本地无限堆积
// 启动时做一次体积兜底清理,防止本地无限堆积(默认不按数量删)
this.pruneOldSessions();
// 启动时补清理「历史遗留空壳」——SessionEnd 没跑到时 cleanupIfBlankSession 从未执行
this.lastBlankPrune = this.pruneStaleBlankSessions();
Expand Down Expand Up @@ -414,49 +440,63 @@ export class TraceCollector {
}

/**
* LRU 清理:当 sessions/ 下目录数超过 maxSessionsRetained 时,按修改时间删最旧的。
* 本地轨迹淘汰:默认**不限数量**,只在总体积超过 `maxTotalBytes`(默认 10GB)时删。
* 优先删已上传的(含 .uploaded 标记)——它们的数据已安全落到远端;
* 未上传的目录(重试队列待传)即使较旧也尽量保留,避免丢失尚未采集到的训练数据。
* 未上传的目录(重试队列待传)即使较旧也尽量保留。活着的进程正在写的会话、
* `minRetention` 窗口内更新过的目录永不删。选择逻辑在 `trace/retention.ts`(纯函数)。
* 失败静默:清理不是关键路径,不能阻塞采集。
*
* ⚠ **P0-2:本函数只清原始轨迹,指标摘要已由 `trace/session-index.ts` 长期留存**
*(`~/.sid-code/session-index.jsonl`,与 trajectories/ 同级,不受本函数影响)。
*
* 不要为了"保住指标"而调大 `maxSessionsRetained` —— 那条路已经论证过是错解:
* 只是把问题推迟到 1000 个会话之后,且届时数据量 ×10。真正需要长留的是每会话
* ≈500B 的摘要,不是 45MB 的原始轨迹,两者差两个数量级。
* ⚠ 2026-10-10 推翻了此前「默认 100 个、不要调大」的定案:那条论证的前提是
* 「原始轨迹 ≈45MB/会话」,实测开发者本机 100 个目录合计 37MB(p95 1.3MB)。
* 一天几十个会话时 100 个只够两三周,调试要的 raw.jsonl / events.jsonl 早被删了。
* 防盘满改由体积上限承担,与会话保留(PR #218)同口径。
*
* 修复前的后果值得记下来:本函数删掉目录时 `session-summary.json` 一起消失,
* 于是 TTFT p50 从文档记录的 4.7s(1032 样本)"变成" 3.3s(1399 样本)——
* **不是性能改善,是样本被换了一批**。一个不可复现的指标证明不了任何改进。
* 指标的长期留存仍由 `trace/session-index.ts` 承担(P0-2)——本函数删掉目录时
* `session-summary.json` 一起消失,曲线不能依赖原始轨迹活着。
*/
private pruneOldSessions(): void {
try {
const sessionsDir = join(this.outputDir, "sessions");
if (!existsSync(sessionsDir)) return;
if (this.maxSessionsRetained === undefined && this.maxTotalBytes === undefined) return;

const entries = readdirSync(sessionsDir, { withFileTypes: true })
const needBytes = this.maxTotalBytes !== undefined;
const entries: TraceDirEntry[] = readdirSync(sessionsDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => {
const dir = join(sessionsDir, e.name);
let mtime = 0;
let mtimeMs = 0;
try {
mtime = statSync(dir).mtimeMs;
mtimeMs = statSync(dir).mtimeMs;
} catch {
/* 忽略 */
}
const uploaded = existsSync(join(dir, ".uploaded"));
return { dir, mtime, uploaded };
return {
id: e.name,
dir,
mtimeMs,
uploaded: existsSync(join(dir, ".uploaded")),
bytes: needBytes ? dirBytes(dir) : undefined,
};
});

if (entries.length <= this.maxSessionsRetained) return;
// 跨进程保护:隔壁 sid-code 正在写的会话(resume 时目录名是被恢复的旧 id)
const protectedIds = new Set<string>();
try {
for (const a of listActiveSessions()) {
protectedIds.add(a.sessionId);
if (a.logicalSessionId) protectedIds.add(a.logicalSessionId);
}
} catch {
/* 读不到活跃表时只靠 minRetention 窗口保护 */
}

const overflow = entries.length - this.maxSessionsRetained;
// 删除优先级:已上传的优先(按最旧在前),其次才动未上传的(同样最旧在前)
const deletable = [
...entries.filter((e) => e.uploaded).sort((a, b) => a.mtime - b.mtime),
...entries.filter((e) => !e.uploaded).sort((a, b) => a.mtime - b.mtime),
].slice(0, overflow);
const deletable = selectTraceDirsToPrune(entries, {
maxCount: this.maxSessionsRetained,
maxTotalBytes: this.maxTotalBytes,
protectAfterMs: Date.now() - this.minRetentionMs,
protectedIds,
});

let removed = 0;
for (const e of deletable) {
Expand All @@ -470,11 +510,12 @@ export class TraceCollector {
if (removed > 0) {
getLogger().info(
"TRACE",
`LRU 清理:本地会话 ${entries.length} 个超过上限 ${this.maxSessionsRetained},已删除最旧 ${removed} 个`,
`轨迹清理:本地 ${entries.length} 个会话目录,已删除最旧 ${removed} 个` +
`(上限:数量 ${this.maxSessionsRetained ?? "不限"},体积 ${this.maxTotalBytes ?? "不限"} 字节)`,
);
}
} catch (err) {
getLogger().warn("TRACE", `LRU 清理失败(不影响采集): ${err}`);
getLogger().warn("TRACE", `轨迹清理失败(不影响采集): ${err}`);
}
}

Expand Down Expand Up @@ -3166,3 +3207,21 @@ export class TraceCollector {
}
}
}

/** 目录递归字节数(单项失败忽略) */
function dirBytes(dir: string): number {
let total = 0;
try {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, e.name);
try {
total += e.isDirectory() ? dirBytes(full) : statSync(full).size;
} catch {
/* 忽略 */
}
}
} catch {
/* 目录不可读 */
}
return total;
}
85 changes: 85 additions & 0 deletions packages/core/src/trace/retention.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* 本地轨迹目录(trajectories/sessions/{id}/)的淘汰选择 —— 纯函数,不碰磁盘。
*
* ## 为什么从「按数量 100」改成「按体积」
*
* 此前 `pruneOldSessions()` 默认只留最近 100 个目录。100 是按「原始轨迹 ≈45MB/会话」
* 推出来的磁盘保护,但实测(2026-10-10,开发者本机 100 个目录)合计只有 37MB:
* p50 24KB、p95 1.3MB、最大 3.2MB —— 那条前提差了一个数量级以上。
* 而一天 24~36 个会话时,100 个只够留 2~4 周,`raw.jsonl` / `session.traj` /
* `events.jsonl`(TTFT/TTFB 分组、`trace-digest`、`provider-health` 的数据源)
* 在想回头调试时已经没了。
*
* 与会话保留(`session/retention.ts`,PR #218)同一个思路:真正要防的是盘满,
* 不是「目录多」—— 拿数量近似体积,误伤的恰好是最想留的那批。
* 所以默认**不限数量**,兜底是总体积上限(与 `sessionRetention.maxTotalSize` 共用一个数)。
*
* ## 淘汰顺序与保护
*
* - 已上传的(有 `.uploaded`,数据已在远端)优先删,其次才动未上传的;各自最旧在前。
* - **受保护的永不删**:活着的进程正在写的会话、`minRetention` 窗口内更新过的目录。
* 宁可暂时超限,也不删正在写的轨迹 —— 与会话清理的口径一致。
*/

export interface TraceDirEntry {
/** 目录名 = trace session id */
id: string;
dir: string;
mtimeMs: number;
uploaded: boolean;
/** 目录字节数;只在启用体积上限时需要 */
bytes?: number;
}

export interface TracePrunePolicy {
/** 显式数量上限;undefined = 不限(默认) */
maxCount?: number;
/** 总体积上限(字节);undefined = 不按体积删 */
maxTotalBytes?: number;
/** 该时间点之后更新过的目录不删(minRetention 窗口) */
protectAfterMs?: number;
/** 正被活着的进程使用的 session id */
protectedIds?: ReadonlySet<string>;
}

export function selectTraceDirsToPrune(
entries: readonly TraceDirEntry[],
policy: TracePrunePolicy,
): TraceDirEntry[] {
const isProtected = (e: TraceDirEntry): boolean =>
(policy.protectedIds?.has(e.id) ?? false) ||
(policy.protectAfterMs !== undefined && e.mtimeMs > policy.protectAfterMs);

// 删除优先级:已上传的优先(最旧在前),其次未上传的(最旧在前)
const byAge = (a: TraceDirEntry, b: TraceDirEntry) => a.mtimeMs - b.mtimeMs;
const candidates = [
...entries.filter((e) => e.uploaded && !isProtected(e)).sort(byAge),
...entries.filter((e) => !e.uploaded && !isProtected(e)).sort(byAge),
];

const selected: TraceDirEntry[] = [];
let next = 0;

const maxCount = policy.maxCount;
if (maxCount !== undefined && Number.isFinite(maxCount) && maxCount > 0) {
let overflow = entries.length - Math.floor(maxCount);
while (overflow > 0 && next < candidates.length) {
selected.push(candidates[next++]);
overflow--;
}
}

const limit = policy.maxTotalBytes;
if (limit !== undefined && Number.isFinite(limit) && limit > 0) {
const chosen = new Set(selected);
let total = 0;
for (const e of entries) if (!chosen.has(e)) total += e.bytes ?? 0;
while (total > limit && next < candidates.length) {
const e = candidates[next++];
selected.push(e);
total -= e.bytes ?? 0;
}
}

return selected;
}
8 changes: 7 additions & 1 deletion packages/core/src/trace/session-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* ## 为什么需要它
*
* `trace/collector.ts` 的 `pruneOldSessions()` 在会话数超过 `maxSessionsRetained`
*(默认 100)时 `rmSync` 整个会话目录,`session-summary.json` 与 `events.jsonl`
*(当时默认 100)时 `rmSync` 整个会话目录,`session-summary.json` 与 `events.jsonl`
* 一起消失。后果不是"少了些历史文件",而是**所有基于轨迹的指标不可复现**:
*
* 实测同一台机器上 TTFT p50 从文档记录的 4.7s(1032 样本)变成 3.3s(1399 样本)。
Expand All @@ -21,6 +21,12 @@
*
* 所以 `pruneOldSessions()` **不改**,只是它不再是唯一的数据留存路径。
*
* ⚠ 2026-10-10 补记:`pruneOldSessions()` 后来**确实改了**——默认不限数量、改为体积兜底
*(见 `trace/retention.ts`)。理由不是「保住指标」(那条仍由本索引承担),而是**调试**:
* 上面「45MB/会话」的前提实测差一个数量级(100 个目录合计 37MB),100 个只够两三周,
* 开发者回头排查时 raw.jsonl / events.jsonl 已经没了。本索引的契约不受影响:
* 体积兜底触发时目录照样会被删,曲线仍不能依赖原始轨迹活着。
*
* ## 设计契约
*
* - **路径与 `trajectories/` 同级**(`~/.sid-code/session-index.jsonl`),不在其下 ——
Expand Down
Loading
Loading