1. 增加统计信息并上报逻辑,用于对账;2. 上报失败超过最大重试次数的消息记录到dead letter jsonl中
This commit is contained in:
parent
455489cc89
commit
14d8e59d8c
|
|
@ -194,8 +194,8 @@
|
||||||
| `CSC_RAW_DUMP_BASE_URL` | string | - | 自定义上报服务端地址 |
|
| `CSC_RAW_DUMP_BASE_URL` | string | - | 自定义上报服务端地址 |
|
||||||
| `COSTRICT_RAW_DUMP_BASE_URL` | string | - | 兼容 opencode 的自定义上报地址 |
|
| `COSTRICT_RAW_DUMP_BASE_URL` | string | - | 兼容 opencode 的自定义上报地址 |
|
||||||
| `COSTRICT_BASE_URL` | string | `https://zgsm.sangfor.com` | CoStrict 服务地址 |
|
| `COSTRICT_BASE_URL` | string | `https://zgsm.sangfor.com` | CoStrict 服务地址 |
|
||||||
| `CSC_RAW_DUMP_LOCAL_MODE` | boolean | `false` | **本地留存模式**:数据只写入本地文件,不上报服务端 |
|
| `CSC_RAW_DUMP_MODE` | boolean | `false` | **本地留存模式**:数据只写入本地文件,不上报服务端 |
|
||||||
| `CSC_RAW_DUMP_LOCAL_DIR` | string | `~/.claude/raw-dump-local` | 本地留存目录 |
|
| `CSC_RAW_DUMP_DIR` | string | `~/.claude/raw-dump` | 本地留存目录 |
|
||||||
|
|
||||||
### Bash/终端
|
### Bash/终端
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
uploadConversation,
|
uploadConversation,
|
||||||
uploadSummary,
|
uploadSummary,
|
||||||
uploadCommits,
|
uploadCommits,
|
||||||
|
uploadStatistics,
|
||||||
authWithFallback,
|
authWithFallback,
|
||||||
} from './worker.js'
|
} from './worker.js'
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,8 +30,7 @@ import {
|
||||||
MAX_ATTEMPTS,
|
MAX_ATTEMPTS,
|
||||||
type QueueTask,
|
type QueueTask,
|
||||||
} from './queue.js'
|
} from './queue.js'
|
||||||
import { readState, writeState } from './state.js'
|
import { readState, writeState, appendDeadLetter } from './state.js'
|
||||||
import type { RawDumpError } from './types.js'
|
|
||||||
import { getSessionDirectory, loadSessionMessages } from './worker.js'
|
import { getSessionDirectory, loadSessionMessages } from './worker.js'
|
||||||
import { getRepoInfo } from './git.js'
|
import { getRepoInfo } from './git.js'
|
||||||
import { createLogger } from './logger.js'
|
import { createLogger } from './logger.js'
|
||||||
|
|
@ -134,6 +134,25 @@ async function processTask(
|
||||||
const authData = await authWithFallback()
|
const authData = await authWithFallback()
|
||||||
const repoInfo = await getCachedRepoInfo(task.directory)
|
const repoInfo = await getCachedRepoInfo(task.directory)
|
||||||
|
|
||||||
|
if (task.messageID === '__statistics__' && task.statsData) {
|
||||||
|
await uploadStatistics(
|
||||||
|
{
|
||||||
|
sessionID: task.sessionID,
|
||||||
|
directory: task.directory,
|
||||||
|
sessionCount: task.statsData.sessionCount,
|
||||||
|
conversationCount: task.statsData.conversationCount,
|
||||||
|
upstreamTokens: task.statsData.upstreamTokens,
|
||||||
|
downstreamTokens: task.statsData.downstreamTokens,
|
||||||
|
startTime: task.statsData.startTime,
|
||||||
|
endTime: task.statsData.endTime,
|
||||||
|
},
|
||||||
|
authData,
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
log.info('statistics task completed', { sessionID: task.sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const conversationUploaded = await uploadConversation(
|
const conversationUploaded = await uploadConversation(
|
||||||
{
|
{
|
||||||
sessionID: task.sessionID,
|
sessionID: task.sessionID,
|
||||||
|
|
@ -156,7 +175,10 @@ async function processTask(
|
||||||
repoInfo,
|
repoInfo,
|
||||||
})
|
})
|
||||||
|
|
||||||
log.info('task completed', { sessionID: task.sessionID, conversationUploaded })
|
log.info('task completed', {
|
||||||
|
sessionID: task.sessionID,
|
||||||
|
conversationUploaded,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -218,13 +240,16 @@ async function runBatch() {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (task.attemptCount >= MAX_ATTEMPTS) {
|
if (task.attemptCount >= MAX_ATTEMPTS) {
|
||||||
// 彻底失败:记录错误,从队列移除
|
// 彻底失败:写入 dead letter,从队列移除
|
||||||
state.errors[key] = {
|
await appendDeadLetter({
|
||||||
message: errorMsg,
|
sessionID: task.sessionID,
|
||||||
count: task.attemptCount,
|
messageID: task.messageID,
|
||||||
lastAt: new Date().toISOString(),
|
directory: task.directory,
|
||||||
|
attemptCount: task.attemptCount,
|
||||||
|
error: errorMsg,
|
||||||
endpoint: extractEndpointFromError(errorMsg),
|
endpoint: extractEndpointFromError(errorMsg),
|
||||||
} satisfies RawDumpError
|
failedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
removeTask(key)
|
removeTask(key)
|
||||||
log.warn('task permanently failed, removed from queue', {
|
log.warn('task permanently failed, removed from queue', {
|
||||||
sessionID: task.sessionID,
|
sessionID: task.sessionID,
|
||||||
|
|
@ -232,12 +257,6 @@ async function runBatch() {
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 失败但未超限:attemptCount++ 写回文件,下次重试
|
// 失败但未超限:attemptCount++ 写回文件,下次重试
|
||||||
state.errors[key] = {
|
|
||||||
message: errorMsg,
|
|
||||||
count: task.attemptCount,
|
|
||||||
lastAt: new Date().toISOString(),
|
|
||||||
endpoint: extractEndpointFromError(errorMsg),
|
|
||||||
} satisfies RawDumpError
|
|
||||||
await flushQueue()
|
await flushQueue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -279,15 +298,17 @@ export function startBatchWorker() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动时加载队列 + 随机抖动
|
// 启动时加载队列 + 随机抖动
|
||||||
loadQueue().then(() => {
|
loadQueue()
|
||||||
log.info('queue loaded', { count: getQueue().length })
|
.then(() => {
|
||||||
scheduleNext(Math.floor(Math.random() * 10_000))
|
log.info('queue loaded', { count: getQueue().length })
|
||||||
}).catch(err => {
|
scheduleNext(Math.floor(Math.random() * 10_000))
|
||||||
log.error('failed to load queue', {
|
})
|
||||||
error: err instanceof Error ? err.message : String(err),
|
.catch(err => {
|
||||||
|
log.error('failed to load queue', {
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
process.exit(1)
|
||||||
})
|
})
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scriptPath = process.argv[1] || ''
|
const scriptPath = process.argv[1] || ''
|
||||||
|
|
@ -296,4 +317,4 @@ if (
|
||||||
scriptPath.endsWith('batchWorker.js')
|
scriptPath.endsWith('batchWorker.js')
|
||||||
) {
|
) {
|
||||||
startBatchWorker()
|
startBatchWorker()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
* 队列模式:主进程只 enqueue,单 batch worker 顺序消费
|
* 队列模式:主进程只 enqueue,单 batch worker 顺序消费
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getRawDumpMode, RAW_DUMP_MODE } from './localStorage.js'
|
import {
|
||||||
|
getRawDumpMode,
|
||||||
|
RAW_DUMP_MODE,
|
||||||
|
} from './localStorage.js'
|
||||||
import { enqueue } from './queue.js'
|
import { enqueue } from './queue.js'
|
||||||
import { spawnBatchWorker } from './spawn.js'
|
import { spawnBatchWorker } from './spawn.js'
|
||||||
import { startBatchWorker } from './batchWorker.js'
|
import { startBatchWorker } from './batchWorker.js'
|
||||||
|
|
@ -103,3 +106,42 @@ export function reportSession(sessionID: string, directory: string): void {
|
||||||
enqueue({ sessionID, messageID: '__summary__', directory })
|
enqueue({ sessionID, messageID: '__summary__', directory })
|
||||||
ensureBatchWorker()
|
ensureBatchWorker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatisticsData {
|
||||||
|
sessionCount: number
|
||||||
|
conversationCount: number
|
||||||
|
upstreamTokens: number
|
||||||
|
downstreamTokens: number
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastReportStatsMap = new Map<string, number>()
|
||||||
|
const STATS_DEBOUNCE_MS = 60_000 // 同一 session 1 分钟内不重复 enqueue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报对账统计数据(session数、conversation数、token数)
|
||||||
|
* 通过特殊 messageID '__statistics__' 标识入队
|
||||||
|
*/
|
||||||
|
export function reportStatistics(
|
||||||
|
sessionID: string,
|
||||||
|
directory: string,
|
||||||
|
data: StatisticsData,
|
||||||
|
): void {
|
||||||
|
if (!isEnabled()) return
|
||||||
|
const key = `${sessionID}:__statistics__`
|
||||||
|
const now = Date.now()
|
||||||
|
const last = lastReportStatsMap.get(key)
|
||||||
|
if (last && now - last < STATS_DEBOUNCE_MS) {
|
||||||
|
log.debug('reportStatistics debounced', { sessionID, lastMs: now - last })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastReportStatsMap.set(key, now)
|
||||||
|
enqueue({
|
||||||
|
sessionID,
|
||||||
|
messageID: '__statistics__',
|
||||||
|
directory,
|
||||||
|
statsData: data,
|
||||||
|
})
|
||||||
|
ensureBatchWorker()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { promises as fs } from 'node:fs'
|
import { promises as fs } from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import type { StatisticsData } from './index.js'
|
||||||
|
|
||||||
const QUEUE_FILE = path.join(
|
const QUEUE_FILE = path.join(
|
||||||
os.homedir(),
|
os.homedir(),
|
||||||
|
|
@ -29,6 +30,7 @@ export interface QueueTask {
|
||||||
directory: string
|
directory: string
|
||||||
enqueuedAt: number
|
enqueuedAt: number
|
||||||
attemptCount: number
|
attemptCount: number
|
||||||
|
statsData?: StatisticsData
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------- 内存中的队列 -----------
|
// ----------- 内存中的队列 -----------
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,22 @@
|
||||||
/**
|
/**
|
||||||
* Raw Dump 磁盘状态管理
|
* Raw Dump 磁盘状态管理
|
||||||
* 用于 conversation、summary、commits 的去重,以及错误聚合
|
* 用于 conversation、summary、commits 的去重
|
||||||
* 通过文件锁保证多进程并发读写安全
|
* 通过文件锁保证多进程并发读写安全
|
||||||
*
|
*
|
||||||
* state.errors:按 sessionID:messageID 键控的错误聚合表
|
* 错误追踪(dead letter):
|
||||||
* - 每批次累加 count,记录最新错误消息和时间戳
|
* - 超过最大重试次数的任务追加写入 dead letter jsonl 文件
|
||||||
* - 超过最大重试次数时由 batchWorker 写入 dead letter
|
* - 路径: ~/.claude/raw-dump/csc-dead-letter.jsonl
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fs, readFileSync, writeFileSync } from 'node:fs'
|
import { promises as fs, readFileSync, writeFileSync } from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { RawDumpState } from './types.js'
|
import type { RawDumpState, DeadLetterEntry } from './types.js'
|
||||||
|
|
||||||
const STATE_DIR = path.join(os.homedir(), '.claude', 'raw-dump')
|
const STATE_DIR = path.join(os.homedir(), '.claude', 'raw-dump')
|
||||||
const STATE_FILE = path.join(STATE_DIR, 'csc-state.json')
|
const STATE_FILE = path.join(STATE_DIR, 'csc-state.json')
|
||||||
const STATE_LOCK_FILE = path.join(STATE_DIR, 'csc-state.lock')
|
const STATE_LOCK_FILE = path.join(STATE_DIR, 'csc-state.lock')
|
||||||
|
const DEAD_LETTER_FILE = path.join(STATE_DIR, 'csc-dead-letter.jsonl')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建空的 RawDumpState 对象
|
* 创建空的 RawDumpState 对象
|
||||||
|
|
@ -26,8 +27,6 @@ function createEmptyState(): RawDumpState {
|
||||||
conversation: {},
|
conversation: {},
|
||||||
summary: {},
|
summary: {},
|
||||||
commits: {},
|
commits: {},
|
||||||
errors: {},
|
|
||||||
// summary 值为 RFC3339 格式时间戳字符串,解析时无需转换
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,7 +105,6 @@ export async function readState(): Promise<RawDumpState> {
|
||||||
conversation: parsed.conversation ?? {},
|
conversation: parsed.conversation ?? {},
|
||||||
summary: parsed.summary ?? {},
|
summary: parsed.summary ?? {},
|
||||||
commits: parsed.commits ?? {},
|
commits: parsed.commits ?? {},
|
||||||
errors: parsed.errors ?? {},
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return createEmptyState()
|
return createEmptyState()
|
||||||
|
|
@ -126,3 +124,23 @@ export async function writeState(state: RawDumpState): Promise<void> {
|
||||||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8')
|
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), 'utf-8')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readDeadLetter(): Promise<DeadLetterEntry[]> {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(DEAD_LETTER_FILE, 'utf-8')
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(line => JSON.parse(line) as DeadLetterEntry)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendDeadLetter(entry: DeadLetterEntry): Promise<void> {
|
||||||
|
await fs.mkdir(STATE_DIR, { recursive: true })
|
||||||
|
await fs.writeFile(DEAD_LETTER_FILE, JSON.stringify(entry) + '\n', {
|
||||||
|
flag: 'a',
|
||||||
|
encoding: 'utf-8',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,9 @@
|
||||||
* - summary:会话统计(按 session 去重,5 分钟内只上报一次)
|
* - summary:会话统计(按 session 去重,5 分钟内只上报一次)
|
||||||
* - commits:提交记录(按 commit ID 去重)
|
* - commits:提交记录(按 commit ID 去重)
|
||||||
*
|
*
|
||||||
* 错误追踪:
|
* 错误追踪(dead letter):
|
||||||
* - 上报失败的任务写入 failed queue,最多重试 MAX_RETRIES 次
|
* - 超过最大重试次数的任务追加写入 dead letter jsonl 文件
|
||||||
* - 超过最大重试次数的任务移入 dead letter 文件
|
* - 路径: ~/.claude/raw-dump/csc-dead-letter.jsonl
|
||||||
* - 所有错误聚合到 state.errors,按 sessionID:messageID 键控
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const RAW_DUMP_EVENT_ENV_KEY = '__CSC_RAW_DUMP_EVENT__'
|
export const RAW_DUMP_EVENT_ENV_KEY = '__CSC_RAW_DUMP_EVENT__'
|
||||||
|
|
@ -25,14 +24,16 @@ export interface RawDumpState {
|
||||||
conversation: Record<string, true>
|
conversation: Record<string, true>
|
||||||
summary: Record<string, string> // RFC3339 时间戳,如 "2024-01-01T12:00:00.000Z"
|
summary: Record<string, string> // RFC3339 时间戳,如 "2024-01-01T12:00:00.000Z"
|
||||||
commits: Record<string, string>
|
commits: Record<string, string>
|
||||||
errors: Record<string, RawDumpError>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RawDumpError {
|
export interface DeadLetterEntry {
|
||||||
message: string
|
sessionID: string
|
||||||
count: number
|
messageID: string
|
||||||
lastAt: string
|
directory: string
|
||||||
|
attemptCount: number
|
||||||
|
error: string
|
||||||
endpoint?: string
|
endpoint?: string
|
||||||
|
failedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JwtPayload {
|
export interface JwtPayload {
|
||||||
|
|
@ -107,3 +108,17 @@ export interface CommitPayload {
|
||||||
subject: string
|
subject: string
|
||||||
parent_ids: string[]
|
parent_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatisticsPayload {
|
||||||
|
task_id: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
user_id: string
|
||||||
|
user_name: string
|
||||||
|
client_id: string
|
||||||
|
client_version: string
|
||||||
|
session_count: number
|
||||||
|
conversation_count: number
|
||||||
|
upstream_tokens: number
|
||||||
|
downstream_tokens: number
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import type {
|
||||||
CommitPayload,
|
CommitPayload,
|
||||||
ConversationPayload,
|
ConversationPayload,
|
||||||
JwtPayload,
|
JwtPayload,
|
||||||
|
StatisticsPayload,
|
||||||
SummaryPayload,
|
SummaryPayload,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
@ -1072,6 +1073,64 @@ export async function uploadCommits(
|
||||||
return commits.length
|
return commits.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATS_DEDUP_WINDOW_MS = 60 * 60 * 1000 // 同一 session 1 小时内只上报一次 statistics
|
||||||
|
|
||||||
|
export async function uploadStatistics(
|
||||||
|
payload: {
|
||||||
|
sessionID: string
|
||||||
|
directory: string
|
||||||
|
sessionCount: number
|
||||||
|
conversationCount: number
|
||||||
|
upstreamTokens: number
|
||||||
|
downstreamTokens: number
|
||||||
|
startTime: number
|
||||||
|
endTime: number
|
||||||
|
},
|
||||||
|
authData: Awaited<ReturnType<typeof auth>>,
|
||||||
|
state: Awaited<ReturnType<typeof readState>>,
|
||||||
|
): Promise<void> {
|
||||||
|
const key = `stats:${payload.sessionID}`
|
||||||
|
const lastReported = state.summary[key]
|
||||||
|
if (
|
||||||
|
lastReported &&
|
||||||
|
Date.now() - new Date(lastReported).getTime() < STATS_DEDUP_WINDOW_MS
|
||||||
|
) {
|
||||||
|
log.debug('statistics skipped: reported recently', {
|
||||||
|
task_id: payload.sessionID,
|
||||||
|
lastReported,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: StatisticsPayload = {
|
||||||
|
task_id: payload.sessionID,
|
||||||
|
start_time: formatIso(payload.startTime),
|
||||||
|
end_time: formatIso(payload.endTime),
|
||||||
|
...authData.user,
|
||||||
|
client_id: authData.clientId,
|
||||||
|
client_version: authData.version,
|
||||||
|
session_count: payload.sessionCount,
|
||||||
|
conversation_count: payload.conversationCount,
|
||||||
|
upstream_tokens: payload.upstreamTokens,
|
||||||
|
downstream_tokens: payload.downstreamTokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
await postJson(
|
||||||
|
authData.baseUrl,
|
||||||
|
authData.headers,
|
||||||
|
'/raw-store/statistics',
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
state.summary[key] = new Date().toISOString()
|
||||||
|
log.info('statistics uploaded', {
|
||||||
|
task_id: payload.sessionID,
|
||||||
|
session_count: payload.sessionCount,
|
||||||
|
conversation_count: payload.conversationCount,
|
||||||
|
upstream_tokens: payload.upstreamTokens,
|
||||||
|
downstream_tokens: payload.downstreamTokens,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从环境变量中解析 worker 入口传入的 payload
|
* 从环境变量中解析 worker 入口传入的 payload
|
||||||
* 抛出错误如果环境变量不存在或 JSON 解析失败
|
* 抛出错误如果环境变量不存在或 JSON 解析失败
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user