@dijkspicy/opencode-stats-reporter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +198 -0
- package/README.md +135 -0
- package/bin/opencode-stats-reporter.js +7 -0
- package/contrib/opencode-session-reporter/README.md +92 -0
- package/contrib/opencode-session-reporter/package.json +16 -0
- package/contrib/opencode-session-reporter/plugin-server.ts +14 -0
- package/contrib/opencode-session-reporter/reporter.test.ts +378 -0
- package/contrib/opencode-session-reporter/session-usage-reporter.ts +610 -0
- package/package.json +30 -0
- package/panel/config.example.js +35 -0
- package/panel/css/style.css +615 -0
- package/panel/index.html +398 -0
- package/panel/js/api.js +154 -0
- package/panel/js/app.js +1167 -0
- package/panel/js/charts.js +241 -0
- package/panel/js/config.js +125 -0
- package/panel/js/format.js +156 -0
- package/panel/js/sessions.js +412 -0
- package/src/cli.js +164 -0
- package/src/config.js +57 -0
- package/src/identity.js +34 -0
- package/src/paths.js +37 -0
- package/src/proxy.js +31 -0
- package/src/server.js +164 -0
- package/src/session-api.js +97 -0
- package/src/store.js +246 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-usage-reporter — opencode 客户端会话 token 用量上报插件
|
|
3
|
+
*
|
|
4
|
+
* 行为概要:
|
|
5
|
+
* - 在会话空闲(bus 事件 `session.idle`,或 `session.status` 且 `status.type === "idle"`)时,
|
|
6
|
+
* 读取该会话及其全部后代子会话的「已完成」助手消息 token 用量,按会话各自聚合为快照。
|
|
7
|
+
* - 父会话快照绝不并入子会话用量;每个会话(含子代理会话)都是独立记录。
|
|
8
|
+
* - token 口径原样分列上报(input / output / reasoning / cache.read / cache.write),
|
|
9
|
+
* 不在客户端做任何相加或换算(`output` 不含 reasoning,`input` 不含 cache.read)。
|
|
10
|
+
* - hook 只触发后台采集并入队,绝不等待网络;由后台 flush 统一 POST 到 SESSION_USAGE_ENDPOINT。
|
|
11
|
+
* - 有界内存队列(默认 500 条快照,溢出丢最旧)+ 指数退避(带抖动)+ AbortSignal.timeout 超时。
|
|
12
|
+
* - `dispose` 钩子在进程退出时以约 3 秒预算尽力刷新队列,超时即放弃,绝不阻塞退出。
|
|
13
|
+
* - 载荷不含任何消费者身份字段;身份完全由网关(key-auth 注入 X-Forward-Consumer)决定。
|
|
14
|
+
*
|
|
15
|
+
* 修订号(revision)选型:
|
|
16
|
+
* 直接使用「该会话已完成助手消息条数」。追加式会话中随时间单调递增;重复上报同一数值
|
|
17
|
+
* 对服务端幂等无害(upsert 按 (consumer, install_id, session_id) + 单调守卫);若会话
|
|
18
|
+
* 压缩/删消息导致回退,服务端单调守卫会拒绝旧修订,数据不会倒退。
|
|
19
|
+
*
|
|
20
|
+
* 已在 opencode 1.18.33 上核实的 loader 行为(勿破坏):
|
|
21
|
+
* - 本文件所有运行时导出必须都是函数,否则 loader 抛 "Plugin export is not a function"。
|
|
22
|
+
* - loader 会把【每一个】函数导出都当插件工厂调用一次(参数是插件输入
|
|
23
|
+
* { client, project, worktree, directory, experimental_workspace, serverUrl, $ }),
|
|
24
|
+
* 并对返回值做 `V.event?.(...)` / `V.dispose?.()` 访问。
|
|
25
|
+
* 因此每个纯辅助导出在收到「插件输入形态」的首参时必须返回无害对象 `{}`,
|
|
26
|
+
* 绝不能抛错、返回 null 或 undefined(否则 `V.event?.` 会读到空值导致崩溃)。
|
|
27
|
+
*
|
|
28
|
+
* 安装:复制本文件到 ~/.config/opencode/plugins/(全局)或 <项目>/.opencode/plugins/(项目级)。
|
|
29
|
+
* 启用:设置环境变量 SESSION_USAGE_ENDPOINT(未设置时插件保持惰性)。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
33
|
+
import { randomUUID } from "node:crypto"
|
|
34
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises"
|
|
35
|
+
import { homedir } from "node:os"
|
|
36
|
+
import { dirname, join } from "node:path"
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// 常量(模块私有:loader 要求所有导出必须是函数,因此常量一律不导出)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const SCHEMA_VERSION = 1
|
|
43
|
+
const PLUGIN_VERSION = "0.1.0"
|
|
44
|
+
/** 队列容量:最多积压 500 条会话快照,溢出丢最旧 */
|
|
45
|
+
const QUEUE_CAP = 500
|
|
46
|
+
/** 单次上报请求超时(AbortSignal.timeout) */
|
|
47
|
+
const REQUEST_TIMEOUT_MS = 10_000
|
|
48
|
+
/** 退避基值:第 n 次失败后延迟 ≈ RETRY_BASE_MS * 2^n,封顶 RETRY_MAX_MS,再乘 [0.5, 1.5) 抖动 */
|
|
49
|
+
const RETRY_BASE_MS = 1_000
|
|
50
|
+
const RETRY_MAX_MS = 5 * 60_000
|
|
51
|
+
/** 入队后延迟 flush 的去抖窗口 */
|
|
52
|
+
const FLUSH_DEBOUNCE_MS = 250
|
|
53
|
+
/** dispose 刷新预算 */
|
|
54
|
+
const DISPOSE_BUDGET_MS = 3_000
|
|
55
|
+
/** 子会话递归深度上限(防御环/异常数据) */
|
|
56
|
+
const MAX_SESSION_DEPTH = 32
|
|
57
|
+
|
|
58
|
+
const INSTALL_ID_STATE_FILE = () => join(homedir(), ".local", "state", "opencode-session-reporter", "install_id")
|
|
59
|
+
|
|
60
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// 类型(type-only 导出在运行时会被擦除,不影响 loader 的「导出必须全是函数」校验)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export type UsageMessageInfo = {
|
|
67
|
+
id?: string
|
|
68
|
+
role?: string
|
|
69
|
+
modelID?: string
|
|
70
|
+
providerID?: string
|
|
71
|
+
mode?: string
|
|
72
|
+
tokens?: {
|
|
73
|
+
input?: number
|
|
74
|
+
output?: number
|
|
75
|
+
reasoning?: number
|
|
76
|
+
cache?: { read?: number; write?: number }
|
|
77
|
+
}
|
|
78
|
+
time?: { completed?: number | null }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `client.session.messages` 返回的条目中与本插件相关的最小形状 */
|
|
82
|
+
export type UsageMessage = { info: UsageMessageInfo }
|
|
83
|
+
|
|
84
|
+
/** 单个会话的用量快照(同时是上报载荷里 sessions 数组条目的线上格式) */
|
|
85
|
+
export type SessionSnapshot = {
|
|
86
|
+
session_id: string
|
|
87
|
+
revision: number
|
|
88
|
+
model: string | null
|
|
89
|
+
agent: string | null
|
|
90
|
+
tokens_input: number
|
|
91
|
+
tokens_output: number
|
|
92
|
+
tokens_reasoning: number
|
|
93
|
+
tokens_cache_read: number
|
|
94
|
+
tokens_cache_write: number
|
|
95
|
+
message_count: number
|
|
96
|
+
session_created_at: number | null
|
|
97
|
+
last_activity_at: number | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type BoundedQueue = {
|
|
101
|
+
readonly capacity: number
|
|
102
|
+
readonly size: number
|
|
103
|
+
readonly dropped: number
|
|
104
|
+
push: (item: SessionSnapshot) => void
|
|
105
|
+
drainAll: () => SessionSnapshot[]
|
|
106
|
+
restore: (items: readonly SessionSnapshot[]) => void
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type ReportPayload = {
|
|
110
|
+
schema_version: number
|
|
111
|
+
install_id: string
|
|
112
|
+
reporter: { client: string; version: string }
|
|
113
|
+
sessions: SessionSnapshot[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 本插件实际用到的 opencode SDK client 最小形状(便于 mock 测试) */
|
|
117
|
+
export type ReporterClient = {
|
|
118
|
+
session: {
|
|
119
|
+
messages: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
|
|
120
|
+
children: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
|
|
121
|
+
get?: (args: { path: { id: string }; query?: { directory?: string } }) => Promise<{ data?: unknown }>
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 可注入的定时器(测试用手动调度器替换,避免真实 setTimeout) */
|
|
126
|
+
export type ReporterScheduler = (fn: () => void, delayMs: number) => () => void
|
|
127
|
+
|
|
128
|
+
export type ReporterCoreOptions = {
|
|
129
|
+
client: ReporterClient
|
|
130
|
+
installId: string
|
|
131
|
+
endpoint?: string
|
|
132
|
+
directory?: string
|
|
133
|
+
reporterVersion?: string
|
|
134
|
+
authToken?: string
|
|
135
|
+
fetchFn?: typeof fetch
|
|
136
|
+
queue?: BoundedQueue
|
|
137
|
+
scheduler?: ReporterScheduler
|
|
138
|
+
flushDebounceMs?: number
|
|
139
|
+
requestTimeoutMs?: number
|
|
140
|
+
log?: (message: string, error?: unknown) => void
|
|
141
|
+
random?: () => number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type ReporterCore = {
|
|
145
|
+
handleEvent: (event?: { type?: unknown; properties?: unknown }) => Promise<void>
|
|
146
|
+
collectSession: (sessionID: string) => Promise<void>
|
|
147
|
+
flushOnce: () => Promise<boolean>
|
|
148
|
+
disposeFlush: (budgetMs?: number) => Promise<void>
|
|
149
|
+
readonly queue: BoundedQueue
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 通用小工具
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
const toCount = (value: unknown): number =>
|
|
157
|
+
typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0
|
|
158
|
+
|
|
159
|
+
const joinModel = (providerID?: string, modelID?: string): string | null =>
|
|
160
|
+
providerID && modelID ? `${providerID}/${modelID}` : null
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 判断首参是否是 opencode loader 传入的插件输入。
|
|
164
|
+
* loader 会对本文件所有函数导出各调用一次 `export(ctxInput, options)`。
|
|
165
|
+
*/
|
|
166
|
+
const isPluginInput = (value: unknown): boolean => {
|
|
167
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
|
168
|
+
return (
|
|
169
|
+
"client" in value &&
|
|
170
|
+
("project" in value || "worktree" in value || "experimental_workspace" in value || "serverUrl" in value)
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 包装纯辅助导出:被 loader 误当插件工厂调用时返回 `{}`(安全、可被 `V.event?.` 访问),
|
|
176
|
+
* 正常按签名调用时行为与原函数完全一致。
|
|
177
|
+
*/
|
|
178
|
+
const pluginSafe = <A extends unknown[], R>(fn: (...args: A) => R): ((...args: A) => R) => {
|
|
179
|
+
return (...args: A): R => {
|
|
180
|
+
if (isPluginInput(args[0])) return {} as R
|
|
181
|
+
return fn(...args)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// 纯函数:聚合 / 合并 / 载荷 / 队列 / 退避
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
const aggregateSessionImpl = (sessionID: string, messages: readonly UsageMessage[]): SessionSnapshot | null => {
|
|
190
|
+
let input = 0
|
|
191
|
+
let output = 0
|
|
192
|
+
let reasoning = 0
|
|
193
|
+
let cacheRead = 0
|
|
194
|
+
let cacheWrite = 0
|
|
195
|
+
let count = 0
|
|
196
|
+
let lastActivity: number | null = null
|
|
197
|
+
let model: string | null = null
|
|
198
|
+
let agent: string | null = null
|
|
199
|
+
|
|
200
|
+
for (const message of messages ?? []) {
|
|
201
|
+
const info = message?.info
|
|
202
|
+
if (!info || info.role !== "assistant") continue
|
|
203
|
+
const completed = info.time?.completed
|
|
204
|
+
// 只统计已完成(time.completed 已设置)的助手消息,天然去重、不双计
|
|
205
|
+
if (typeof completed !== "number" || !Number.isFinite(completed)) continue
|
|
206
|
+
count += 1
|
|
207
|
+
input += toCount(info.tokens?.input)
|
|
208
|
+
output += toCount(info.tokens?.output)
|
|
209
|
+
reasoning += toCount(info.tokens?.reasoning)
|
|
210
|
+
cacheRead += toCount(info.tokens?.cache?.read)
|
|
211
|
+
cacheWrite += toCount(info.tokens?.cache?.write)
|
|
212
|
+
if (lastActivity === null || completed > lastActivity) lastActivity = completed
|
|
213
|
+
const messageModel = joinModel(info.providerID, info.modelID)
|
|
214
|
+
if (messageModel) {
|
|
215
|
+
// 取最后一条已完成助手消息的模型 / agent 作为会话级归属
|
|
216
|
+
model = messageModel
|
|
217
|
+
agent = typeof info.mode === "string" ? info.mode : null
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (count === 0) return null
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
session_id: sessionID,
|
|
225
|
+
revision: count, // 修订号 = 已完成助手消息数(见文件头说明)
|
|
226
|
+
model,
|
|
227
|
+
agent,
|
|
228
|
+
tokens_input: input,
|
|
229
|
+
tokens_output: output,
|
|
230
|
+
tokens_reasoning: reasoning,
|
|
231
|
+
tokens_cache_read: cacheRead,
|
|
232
|
+
tokens_cache_write: cacheWrite,
|
|
233
|
+
message_count: count,
|
|
234
|
+
session_created_at: null, // 由采集层补充
|
|
235
|
+
last_activity_at: lastActivity,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 合并同一会话的多个快照:保留修订号最高者;同修订取 last_activity 更新(或后到)者 */
|
|
240
|
+
const mergeSnapshotsImpl = (snapshots: readonly SessionSnapshot[]): SessionSnapshot[] => {
|
|
241
|
+
const byID = new Map<string, SessionSnapshot>()
|
|
242
|
+
for (const snapshot of snapshots ?? []) {
|
|
243
|
+
if (!snapshot || typeof snapshot.session_id !== "string" || !snapshot.session_id) continue
|
|
244
|
+
const existing = byID.get(snapshot.session_id)
|
|
245
|
+
if (!existing || snapshot.revision > existing.revision) {
|
|
246
|
+
byID.set(snapshot.session_id, snapshot)
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
if (snapshot.revision === existing.revision) {
|
|
250
|
+
const existingAt = existing.last_activity_at ?? 0
|
|
251
|
+
const incomingAt = snapshot.last_activity_at ?? 0
|
|
252
|
+
if (incomingAt >= existingAt) byID.set(snapshot.session_id, snapshot)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return [...byID.values()]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 组装上报载荷。绝不包含消费者身份字段 —— 身份由网关注入。 */
|
|
259
|
+
const buildPayloadImpl = (
|
|
260
|
+
installId: string,
|
|
261
|
+
reporterVersion: string,
|
|
262
|
+
snapshots: readonly SessionSnapshot[],
|
|
263
|
+
): ReportPayload => ({
|
|
264
|
+
schema_version: SCHEMA_VERSION,
|
|
265
|
+
install_id: installId,
|
|
266
|
+
reporter: { client: "opencode", version: reporterVersion },
|
|
267
|
+
sessions: mergeSnapshotsImpl(snapshots),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/** 有界队列:溢出丢最旧;失败批次按原顺序放回队首 */
|
|
271
|
+
const createQueueImpl = (cap: number = QUEUE_CAP): BoundedQueue => {
|
|
272
|
+
const capacity = typeof cap === "number" && Number.isFinite(cap) && cap >= 1 ? Math.floor(cap) : 1
|
|
273
|
+
let items: SessionSnapshot[] = []
|
|
274
|
+
let dropped = 0
|
|
275
|
+
return {
|
|
276
|
+
capacity,
|
|
277
|
+
get size() {
|
|
278
|
+
return items.length
|
|
279
|
+
},
|
|
280
|
+
get dropped() {
|
|
281
|
+
return dropped
|
|
282
|
+
},
|
|
283
|
+
push(item) {
|
|
284
|
+
if (!item) return
|
|
285
|
+
if (items.length >= capacity) {
|
|
286
|
+
items.shift()
|
|
287
|
+
dropped += 1
|
|
288
|
+
}
|
|
289
|
+
items.push(item)
|
|
290
|
+
},
|
|
291
|
+
drainAll() {
|
|
292
|
+
const drained = items
|
|
293
|
+
items = []
|
|
294
|
+
return drained
|
|
295
|
+
},
|
|
296
|
+
restore(incoming) {
|
|
297
|
+
const valid = (incoming ?? []).filter((item) => !!item)
|
|
298
|
+
items = [...valid, ...items]
|
|
299
|
+
// 极端情况下放回后超容量:保最旧(先产生的数据),裁掉最新
|
|
300
|
+
if (items.length > capacity) {
|
|
301
|
+
dropped += items.length - capacity
|
|
302
|
+
items = items.slice(0, capacity)
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 指数退避 + 抖动:delay = min(RETRY_MAX_MS, BASE * 2^attempt) * [0.5, 1.5),结果封顶 RETRY_MAX_MS */
|
|
309
|
+
const computeBackoffMsImpl = (attempt: number, random01: number = Math.random()): number => {
|
|
310
|
+
const safeAttempt =
|
|
311
|
+
typeof attempt === "number" && Number.isFinite(attempt) ? Math.max(0, Math.floor(attempt)) : 0
|
|
312
|
+
const exponential = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** safeAttempt)
|
|
313
|
+
const safeRandom =
|
|
314
|
+
typeof random01 === "number" && Number.isFinite(random01) ? Math.min(1, Math.max(0, random01)) : 0.5
|
|
315
|
+
const jittered = Math.round(exponential * (0.5 + safeRandom))
|
|
316
|
+
return Math.min(RETRY_MAX_MS, Math.max(0, jittered))
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// install_id:本地稳定 UUID(生成一次,持久化到用户状态目录)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
const loadOrCreateInstallIdImpl = async (
|
|
324
|
+
options: { stateFile?: string; randomUUIDFn?: () => string } = {},
|
|
325
|
+
): Promise<string> => {
|
|
326
|
+
const stateFile = options.stateFile ?? INSTALL_ID_STATE_FILE()
|
|
327
|
+
try {
|
|
328
|
+
const existing = (await readFile(stateFile, "utf8")).trim()
|
|
329
|
+
if (UUID_PATTERN.test(existing)) return existing
|
|
330
|
+
} catch {
|
|
331
|
+
// 首次运行或不可读:走生成流程
|
|
332
|
+
}
|
|
333
|
+
const id = options.randomUUIDFn ? options.randomUUIDFn() : randomUUID()
|
|
334
|
+
try {
|
|
335
|
+
await mkdir(dirname(stateFile), { recursive: true })
|
|
336
|
+
await writeFile(stateFile, `${id}\n`, "utf8")
|
|
337
|
+
} catch {
|
|
338
|
+
// 只读文件系统等场景:退化为本次进程内的临时 id
|
|
339
|
+
}
|
|
340
|
+
return id
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// 核心:采集 + 非阻塞队列 + 退避重试 + dispose 限时刷新
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
const defaultScheduler: ReporterScheduler = (fn, delayMs) => {
|
|
348
|
+
const timer = setTimeout(fn, Math.max(0, delayMs))
|
|
349
|
+
const unref = (timer as unknown as { unref?: () => void }).unref
|
|
350
|
+
if (typeof unref === "function") unref.call(timer) // 定时器不阻止进程退出
|
|
351
|
+
return () => clearTimeout(timer)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const createReporterCoreImpl = (options: ReporterCoreOptions): ReporterCore => {
|
|
355
|
+
const queue = options.queue ?? createQueueImpl(QUEUE_CAP)
|
|
356
|
+
const fetchFn = options.fetchFn ?? fetch
|
|
357
|
+
const endpoint = typeof options.endpoint === "string" && options.endpoint.trim() ? options.endpoint.trim() : undefined
|
|
358
|
+
const reporterVersion = options.reporterVersion ?? PLUGIN_VERSION
|
|
359
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS
|
|
360
|
+
const flushDebounceMs = options.flushDebounceMs ?? FLUSH_DEBOUNCE_MS
|
|
361
|
+
const scheduler = options.scheduler ?? defaultScheduler
|
|
362
|
+
const random = options.random ?? Math.random
|
|
363
|
+
const log = options.log ?? (() => {})
|
|
364
|
+
|
|
365
|
+
let flushCancel: (() => void) | null = null
|
|
366
|
+
let retryCancel: (() => void) | null = null
|
|
367
|
+
let retryAttempt = 0
|
|
368
|
+
const inFlight = new Set<string>()
|
|
369
|
+
|
|
370
|
+
const send = async (snapshots: readonly SessionSnapshot[], timeoutMs: number): Promise<void> => {
|
|
371
|
+
if (!endpoint) return
|
|
372
|
+
const payload = buildPayloadImpl(options.installId, reporterVersion, snapshots)
|
|
373
|
+
const abortTimeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal } | undefined)?.timeout
|
|
374
|
+
const signal = typeof abortTimeout === "function" ? abortTimeout(Math.max(1, timeoutMs)) : undefined
|
|
375
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
376
|
+
// 载荷不含身份;上报请求需带网关 Key,由 APIG key-auth 校验后注入消费者身份。
|
|
377
|
+
if (options.authToken) headers["authorization"] = `Bearer ${options.authToken}`
|
|
378
|
+
const response = await fetchFn(endpoint, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers,
|
|
381
|
+
body: JSON.stringify(payload),
|
|
382
|
+
signal,
|
|
383
|
+
})
|
|
384
|
+
if (!response.ok) throw new Error(`session-usage endpoint responded ${response.status}`)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 发送当前全部积压;失败则原样放回队首并调度退避重试。返回是否成功(或无事可做)。 */
|
|
388
|
+
const flushOnce = async (): Promise<boolean> => {
|
|
389
|
+
if (!endpoint) return true
|
|
390
|
+
const batch = queue.drainAll()
|
|
391
|
+
if (batch.length === 0) return true
|
|
392
|
+
try {
|
|
393
|
+
await send(batch, requestTimeoutMs)
|
|
394
|
+
retryAttempt = 0
|
|
395
|
+
return true
|
|
396
|
+
} catch (error) {
|
|
397
|
+
queue.restore(batch)
|
|
398
|
+
retryAttempt += 1
|
|
399
|
+
// 第 1 次失败 → 基础 1s,此后指数翻倍(2s、4s…),封顶 5 分钟,均带抖动
|
|
400
|
+
const delay = computeBackoffMsImpl(Math.max(0, retryAttempt - 1), random())
|
|
401
|
+
log(`session-usage: 上报失败,${delay}ms 后重试(队列积压 ${queue.size})`, error)
|
|
402
|
+
if (!retryCancel) {
|
|
403
|
+
retryCancel = scheduler(() => {
|
|
404
|
+
retryCancel = null
|
|
405
|
+
void flushOnce()
|
|
406
|
+
}, delay)
|
|
407
|
+
}
|
|
408
|
+
return false
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const scheduleFlush = (): void => {
|
|
413
|
+
if (flushCancel || queue.size === 0) return
|
|
414
|
+
flushCancel = scheduler(() => {
|
|
415
|
+
flushCancel = null
|
|
416
|
+
void flushOnce()
|
|
417
|
+
}, flushDebounceMs)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const query = (): { directory: string } | undefined =>
|
|
421
|
+
options.directory ? { directory: options.directory } : undefined
|
|
422
|
+
|
|
423
|
+
const readMessages = async (sessionID: string): Promise<UsageMessage[]> => {
|
|
424
|
+
const response = await options.client.session.messages({ path: { id: sessionID }, query: query() })
|
|
425
|
+
const data = (response as { data?: unknown } | undefined)?.data
|
|
426
|
+
return Array.isArray(data) ? (data as UsageMessage[]) : []
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const readChildIDs = async (sessionID: string): Promise<string[]> => {
|
|
430
|
+
const response = await options.client.session.children({ path: { id: sessionID }, query: query() })
|
|
431
|
+
const data = (response as { data?: unknown } | undefined)?.data
|
|
432
|
+
if (!Array.isArray(data)) return []
|
|
433
|
+
const ids: string[] = []
|
|
434
|
+
for (const entry of data) {
|
|
435
|
+
const id = typeof entry === "string" ? entry : (entry as { id?: unknown } | null)?.id
|
|
436
|
+
if (typeof id === "string" && id) ids.push(id)
|
|
437
|
+
}
|
|
438
|
+
return ids
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const readSessionCreatedAt = async (sessionID: string): Promise<number | null> => {
|
|
442
|
+
try {
|
|
443
|
+
const getSession = options.client.session.get
|
|
444
|
+
if (typeof getSession !== "function") return null
|
|
445
|
+
const response = await getSession.call(options.client.session, { path: { id: sessionID }, query: query() })
|
|
446
|
+
const created = (response as { data?: { time?: { created?: unknown } } | undefined } | undefined)?.data?.time
|
|
447
|
+
?.created
|
|
448
|
+
return typeof created === "number" ? created : null
|
|
449
|
+
} catch {
|
|
450
|
+
return null
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** 递归聚合会话及其后代;每个会话独立成快照,父快照不含子用量 */
|
|
455
|
+
const collectRecursive = async (
|
|
456
|
+
sessionID: string,
|
|
457
|
+
out: SessionSnapshot[],
|
|
458
|
+
visited: Set<string>,
|
|
459
|
+
depth: number,
|
|
460
|
+
): Promise<void> => {
|
|
461
|
+
if (!sessionID || visited.has(sessionID) || depth > MAX_SESSION_DEPTH || visited.size >= QUEUE_CAP) return
|
|
462
|
+
visited.add(sessionID)
|
|
463
|
+
const snapshot = aggregateSessionImpl(sessionID, await readMessages(sessionID))
|
|
464
|
+
if (snapshot) {
|
|
465
|
+
snapshot.session_created_at = await readSessionCreatedAt(sessionID)
|
|
466
|
+
out.push(snapshot)
|
|
467
|
+
}
|
|
468
|
+
for (const childID of await readChildIDs(sessionID)) {
|
|
469
|
+
await collectRecursive(childID, out, visited, depth + 1)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const collectSession = async (sessionID: string): Promise<void> => {
|
|
474
|
+
if (typeof sessionID !== "string" || !sessionID) return
|
|
475
|
+
if (inFlight.has(sessionID)) return // 同一会话采集中:跳过,下次空闲信号再采
|
|
476
|
+
inFlight.add(sessionID)
|
|
477
|
+
try {
|
|
478
|
+
const out: SessionSnapshot[] = []
|
|
479
|
+
await collectRecursive(sessionID, out, new Set(), 0)
|
|
480
|
+
for (const snapshot of out) queue.push(snapshot)
|
|
481
|
+
if (out.length > 0) scheduleFlush()
|
|
482
|
+
} catch (error) {
|
|
483
|
+
log(`session-usage: 采集会话 ${sessionID} 用量失败`, error)
|
|
484
|
+
} finally {
|
|
485
|
+
inFlight.delete(sessionID)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** 统一处理两种空闲信号;绝不抛出 */
|
|
490
|
+
const handleEvent = async (event?: { type?: unknown; properties?: unknown }): Promise<void> => {
|
|
491
|
+
try {
|
|
492
|
+
const type = event?.type
|
|
493
|
+
const properties = (event?.properties ?? {}) as { sessionID?: unknown; status?: { type?: unknown } }
|
|
494
|
+
let sessionID: unknown
|
|
495
|
+
if (type === "session.idle") {
|
|
496
|
+
sessionID = properties.sessionID
|
|
497
|
+
} else if (type === "session.status" && properties.status?.type === "idle") {
|
|
498
|
+
sessionID = properties.sessionID
|
|
499
|
+
}
|
|
500
|
+
if (typeof sessionID === "string" && sessionID) await collectSession(sessionID)
|
|
501
|
+
} catch (error) {
|
|
502
|
+
log("session-usage: 处理事件失败", error)
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** dispose:限时预算内尽力清空队列;失败立即止损,绝不阻塞退出 */
|
|
507
|
+
const disposeFlush = async (budgetMs: number = DISPOSE_BUDGET_MS): Promise<void> => {
|
|
508
|
+
try {
|
|
509
|
+
flushCancel?.()
|
|
510
|
+
flushCancel = null
|
|
511
|
+
retryCancel?.()
|
|
512
|
+
retryCancel = null
|
|
513
|
+
if (!endpoint) return
|
|
514
|
+
const deadline = Date.now() + Math.max(0, budgetMs)
|
|
515
|
+
while (queue.size > 0) {
|
|
516
|
+
const remaining = deadline - Date.now()
|
|
517
|
+
if (remaining <= 0) break
|
|
518
|
+
const batch = queue.drainAll()
|
|
519
|
+
if (batch.length === 0) break
|
|
520
|
+
try {
|
|
521
|
+
await send(batch, Math.min(requestTimeoutMs, remaining))
|
|
522
|
+
retryAttempt = 0
|
|
523
|
+
} catch (error) {
|
|
524
|
+
queue.restore(batch)
|
|
525
|
+
log("session-usage: 退出前上报失败,放弃剩余队列", error)
|
|
526
|
+
break
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
log("session-usage: dispose 刷新失败", error)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return { handleEvent, collectSession, flushOnce, disposeFlush, queue }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// 导出:全部是函数(loader 硬约束),纯辅助导出带插件工厂防御
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
export const aggregateSession = pluginSafe(aggregateSessionImpl)
|
|
542
|
+
export const mergeSnapshots = pluginSafe(mergeSnapshotsImpl)
|
|
543
|
+
export const buildPayload = pluginSafe(buildPayloadImpl)
|
|
544
|
+
export const createQueue = pluginSafe(createQueueImpl)
|
|
545
|
+
export const computeBackoffMs = pluginSafe(computeBackoffMsImpl)
|
|
546
|
+
export const loadOrCreateInstallId = pluginSafe(loadOrCreateInstallIdImpl)
|
|
547
|
+
export const createReporterCore = pluginSafe(createReporterCoreImpl)
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 插件入口。
|
|
551
|
+
* 未设置 SESSION_USAGE_ENDPOINT 时返回空 hooks,完全不影响会话。
|
|
552
|
+
*/
|
|
553
|
+
export const SessionUsageReporter: Plugin = async (ctx) => {
|
|
554
|
+
try {
|
|
555
|
+
const endpoint = (process.env.SESSION_USAGE_ENDPOINT ?? "").trim() || undefined
|
|
556
|
+
if (!endpoint) return {}
|
|
557
|
+
|
|
558
|
+
const log = (message: string, error?: unknown): void => {
|
|
559
|
+
try {
|
|
560
|
+
const detail = error instanceof Error ? `${error.name}: ${error.message}` : error === undefined ? "" : String(error)
|
|
561
|
+
const client = ctx.client as { app?: { log?: (args: unknown) => unknown } }
|
|
562
|
+
const result = client.app?.log?.({
|
|
563
|
+
body: {
|
|
564
|
+
level: detail ? "error" : "info",
|
|
565
|
+
service: "session-usage-reporter",
|
|
566
|
+
message: detail ? `${message}: ${detail}` : message,
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
const promise = result as Promise<void> | undefined
|
|
570
|
+
if (promise && typeof promise.catch === "function") promise.catch(() => {})
|
|
571
|
+
} catch {
|
|
572
|
+
// 日志失败绝不影响会话
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const installId = await loadOrCreateInstallIdImpl()
|
|
577
|
+
const core = createReporterCoreImpl({
|
|
578
|
+
client: ctx.client as unknown as ReporterClient,
|
|
579
|
+
directory: ctx.directory,
|
|
580
|
+
endpoint,
|
|
581
|
+
installId,
|
|
582
|
+
// 上报请求的网关 Key(APIG key-auth 后注入 X-Forward-Consumer);未配置则不带鉴权头
|
|
583
|
+
authToken: (process.env.SESSION_USAGE_API_KEY ?? "").trim() || undefined,
|
|
584
|
+
log,
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
return {
|
|
588
|
+
event: async (input: { event?: { type?: unknown; properties?: unknown } }) => {
|
|
589
|
+
try {
|
|
590
|
+
// 只触发后台采集,不 await:hook 内禁止等待网络,hooks 串行等待不能被拖慢
|
|
591
|
+
core.handleEvent(input?.event).catch(() => {})
|
|
592
|
+
} catch (error) {
|
|
593
|
+
log("session-usage: hook 执行异常(已忽略)", error)
|
|
594
|
+
}
|
|
595
|
+
},
|
|
596
|
+
dispose: async () => {
|
|
597
|
+
await core.disposeFlush()
|
|
598
|
+
},
|
|
599
|
+
}
|
|
600
|
+
} catch (error) {
|
|
601
|
+
try {
|
|
602
|
+
console.error("[session-usage-reporter] 初始化失败,插件已禁用:", error)
|
|
603
|
+
} catch {
|
|
604
|
+
// 连 console 都失败时保持静默
|
|
605
|
+
}
|
|
606
|
+
return {}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export default SessionUsageReporter
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dijkspicy/opencode-stats-reporter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "本地一体化 MaaS 用量看板:托管静态面板、反代用量接口、本地存储会话用量",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./contrib/opencode-session-reporter/plugin-server.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./contrib/opencode-session-reporter/plugin-server.ts",
|
|
9
|
+
"./server": "./contrib/opencode-session-reporter/plugin-server.ts",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"opencode-stats-reporter": "bin/opencode-stats-reporter.js"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24.0.0"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"src",
|
|
21
|
+
"panel",
|
|
22
|
+
"contrib/opencode-session-reporter",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/*.test.js"
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0"
|
|
30
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BlueRegion Usage 面板配置模板(示例)
|
|
3
|
+
* ===============================================================
|
|
4
|
+
* 使用方法:复制本文件为 config.js,按需修改下方配置项。
|
|
5
|
+
* !! 注意:config.js 会包含你的 API Key,请勿提交 git / 请勿截图分享 !!
|
|
6
|
+
* 优先级:浏览器 localStorage 中保存的配置 > config.js。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
window.APP_CONFIG = {
|
|
10
|
+
// 网关地址(必填):BlueRegion API 网关基础地址,必须是合法 http(s) URL
|
|
11
|
+
// 示例:'https://gateway.example.com';本地联调可用 'http://localhost:8765'
|
|
12
|
+
gatewayBaseUrl: 'http://localhost:8765',
|
|
13
|
+
|
|
14
|
+
// API Key(必填):网关签发的个人 Key,请求时以 Bearer 方式携带
|
|
15
|
+
apiKey: 'api-key-xxxxxxxx',
|
|
16
|
+
|
|
17
|
+
// 趋势图天数(1-15,默认 14):仅"今日"周期下,趋势图回看的天数
|
|
18
|
+
trendDays: 14,
|
|
19
|
+
|
|
20
|
+
// 主题:auto(跟随系统)| light(浅色)| dark(深色),默认 auto
|
|
21
|
+
theme: 'auto',
|
|
22
|
+
|
|
23
|
+
// 大数字缩写风格:wan(万 / 亿,默认)| intl(K / M / B)
|
|
24
|
+
numberStyle: 'wan',
|
|
25
|
+
|
|
26
|
+
// 每日 Credit 限额(可选):今日用量超出该值,Credit 卡红字提示「已超出」。
|
|
27
|
+
// 留空(null)表示不限;可在网页首次登录或「配置限额」中填写,例如 100。
|
|
28
|
+
dailyCreditLimit: null,
|
|
29
|
+
|
|
30
|
+
// 每周 Credit 限额(可选):本周用量对应额度。
|
|
31
|
+
weeklyCreditLimit: null,
|
|
32
|
+
|
|
33
|
+
// 每月 Credit 限额(可选):本月用量对应额度,同时用于「本月进度条」的月均 pacing,例如 3000。
|
|
34
|
+
monthlyCreditLimit: null
|
|
35
|
+
};
|