opencode-tokenwatch 0.2.0 → 0.3.1

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.
@@ -1,16 +1,23 @@
1
- import { appendFileSync } from "node:fs";
2
- import { readFileSync } from "node:fs";
1
+ import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
  import { homedir } from "node:os";
5
- import { existsSync } from "node:fs";
4
+ import { existsSync, statSync } from "node:fs";
5
+ import { updatePersistedStats } from "./stats-store.js";
6
6
  const LOG_PATH = join(homedir(), ".opencode", "tokenwatch.jsonl");
7
7
  class PerfTracker {
8
8
  firstPartTimes = new Map();
9
9
  statsMap = new Map();
10
+ /** 原始样本串,用于分位数计算,不持久化 */
11
+ ttftSamples = new Map();
12
+ latencySamples = new Map();
10
13
  handlePartUpdated(event) {
11
14
  if (!event.time?.start || !event.message_id)
12
15
  return;
13
- this.firstPartTimes.set(event.message_id, event.time.start);
16
+ // Bug fix: 取最早 part 时间而非最后一个,避免 TTFT 被高估
17
+ const cur = this.firstPartTimes.get(event.message_id) ?? Number.POSITIVE_INFINITY;
18
+ if (event.time.start < cur) {
19
+ this.firstPartTimes.set(event.message_id, event.time.start);
20
+ }
14
21
  }
15
22
  handleMessageUpdated(event) {
16
23
  const info = event.properties?.info;
@@ -43,9 +50,9 @@ class PerfTracker {
43
50
  const tps = (genMs !== null && genMs > 0 && outputTokens > 0)
44
51
  ? (outputTokens / genMs) * 1000
45
52
  : null;
46
- const tpsFallback = (tps === null && latencyMs > 0 && outputTokens > 0)
47
- ? (outputTokens / latencyMs) * 1000
48
- : null;
53
+ // Bug fix: 移除 TPS fallback。
54
+ // fallback latencyMs(completed-created,含排队+TTFT)计算 TPS,
55
+ // 会使结果严重低估(约 40%+)。null 表示"无可靠数据"比虚假数字更好。
49
56
  this.firstPartTimes.delete(messageID);
50
57
  const entry = {
51
58
  ts: new Date().toISOString(),
@@ -54,7 +61,7 @@ class PerfTracker {
54
61
  modelID,
55
62
  sessionID,
56
63
  ttft_ms: ttftMs,
57
- tps: tps ?? tpsFallback,
64
+ tps: tps, // 只在有可靠 genMs 时才有值
58
65
  latency_ms: latencyMs,
59
66
  inputTokens,
60
67
  outputTokens,
@@ -68,11 +75,23 @@ class PerfTracker {
68
75
  }
69
76
  appendLog(entry) {
70
77
  try {
78
+ // Risk fix: JSONL 日志轮转保护,防止长期使用后文件无限增长
79
+ // 超过 5MB 时截断,保留最新 2000 行
80
+ // 注意:轮转前先调用 updatePersistedStats,确保被轮转行的数据已持久化
81
+ const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
82
+ const KEEP_LINES = 2000;
83
+ if (existsSync(LOG_PATH) && statSync(LOG_PATH).size > MAX_SIZE) {
84
+ const lines = readFileSync(LOG_PATH, "utf-8").trim().split("\n");
85
+ writeFileSync(LOG_PATH, lines.slice(-KEEP_LINES).join("\n") + "\n");
86
+ }
71
87
  appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
72
88
  }
73
89
  catch {
74
90
  // Silently fail — logging is non-critical
75
91
  }
92
+ // 无论 JSONL 写入是否成功,都尝试更新持久化聚合统计
93
+ // 这样即使日志被轮转,历史统计数据也永不丢失
94
+ updatePersistedStats(entry);
76
95
  }
77
96
  handleMessageRemoved(event) {
78
97
  const mid = event.properties?.messageID ?? "";
@@ -87,6 +106,9 @@ class PerfTracker {
87
106
  model,
88
107
  providerID: entry.providerID,
89
108
  requestCount: 0,
109
+ ttftCount: 0, // Bug fix: 独立维护有效样本计数
110
+ tpsCount: 0,
111
+ latencyCount: 0,
90
112
  totalInput: 0,
91
113
  totalOutput: 0,
92
114
  totalCacheRead: 0,
@@ -95,12 +117,19 @@ class PerfTracker {
95
117
  avgTTFT: null,
96
118
  maxTTFT: null,
97
119
  minTTFT: null,
120
+ p50TTFT: null,
121
+ p95TTFT: null,
122
+ p99TTFT: null,
98
123
  avgTPS: null,
99
124
  maxTPS: null,
100
125
  minTPS: null,
101
126
  avgLatency: null,
102
127
  maxLatency: null,
103
128
  minLatency: null,
129
+ p50Latency: null,
130
+ p95Latency: null,
131
+ p99Latency: null,
132
+ cacheHitRate: null,
104
133
  };
105
134
  this.statsMap.set(model, stats);
106
135
  }
@@ -111,41 +140,87 @@ class PerfTracker {
111
140
  stats.totalCacheWrite += entry.cacheWriteTokens;
112
141
  stats.totalCost += entry.cost;
113
142
  if (entry.ttft_ms !== null) {
114
- const c = stats.requestCount;
143
+ // Bug fix: 分母使用 ttftCount(有效样本数),而非 requestCount(总请求数)
144
+ stats.ttftCount++;
145
+ const c = stats.ttftCount;
115
146
  const prev = stats.avgTTFT;
116
147
  stats.avgTTFT = prev !== null ? prev + (entry.ttft_ms - prev) / c : entry.ttft_ms;
117
148
  stats.maxTTFT = stats.maxTTFT !== null ? Math.max(stats.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
118
149
  stats.minTTFT = stats.minTTFT !== null ? Math.min(stats.minTTFT, entry.ttft_ms) : entry.ttft_ms;
150
+ // 收集原始样本用于分位数计算
151
+ const ttftArr = this.ttftSamples.get(model) ?? [];
152
+ ttftArr.push(entry.ttft_ms);
153
+ this.ttftSamples.set(model, ttftArr);
119
154
  }
120
155
  if (entry.tps !== null) {
121
- const c = stats.requestCount;
156
+ // Bug fix: 分母使用 tpsCount(有效样本数)
157
+ stats.tpsCount++;
158
+ const c = stats.tpsCount;
122
159
  const prev = stats.avgTPS;
123
160
  stats.avgTPS = prev !== null ? prev + (entry.tps - prev) / c : entry.tps;
124
161
  stats.maxTPS = stats.maxTPS !== null ? Math.max(stats.maxTPS, entry.tps) : entry.tps;
125
162
  stats.minTPS = stats.minTPS !== null ? Math.min(stats.minTPS, entry.tps) : entry.tps;
126
163
  }
127
164
  if (entry.latency_ms !== null) {
128
- const c = stats.requestCount;
165
+ // latency 每条消息都有,但保持一致使用专用计数
166
+ stats.latencyCount++;
167
+ const c = stats.latencyCount;
129
168
  const prev = stats.avgLatency;
130
169
  stats.avgLatency = prev !== null ? prev + (entry.latency_ms - prev) / c : entry.latency_ms;
131
170
  stats.maxLatency = stats.maxLatency !== null ? Math.max(stats.maxLatency, entry.latency_ms) : entry.latency_ms;
132
171
  stats.minLatency = stats.minLatency !== null ? Math.min(stats.minLatency, entry.latency_ms) : entry.latency_ms;
172
+ // 收集原始样本用于分位数计算
173
+ const latArr = this.latencySamples.get(model) ?? [];
174
+ latArr.push(entry.latency_ms);
175
+ this.latencySamples.set(model, latArr);
133
176
  }
134
177
  }
178
+ /** 计算有序数组的指定百分位数(线性插值法) */
179
+ percentile(sortedArr, p) {
180
+ if (sortedArr.length === 0)
181
+ return null;
182
+ if (sortedArr.length === 1)
183
+ return sortedArr[0];
184
+ const idx = (p / 100) * (sortedArr.length - 1);
185
+ const lo = Math.floor(idx);
186
+ const hi = Math.ceil(idx);
187
+ if (lo === hi)
188
+ return sortedArr[lo];
189
+ return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
190
+ }
135
191
  getSessionStats() {
136
192
  let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
137
193
  let totalRequests = 0, totalCost = 0;
138
- for (const s of this.statsMap.values()) {
194
+ let weightedHitSum = 0, totalReqForHit = 0;
195
+ for (const [model, s] of this.statsMap) {
139
196
  totalInput += s.totalInput;
140
197
  totalOutput += s.totalOutput;
141
198
  totalCacheRead += s.totalCacheRead;
142
199
  totalCacheWrite += s.totalCacheWrite;
143
200
  totalRequests += s.requestCount;
144
201
  totalCost += s.totalCost;
202
+ // 计算每个模型的分位数(需先排序)
203
+ const ttftArr = [...(this.ttftSamples.get(model) ?? [])].sort((a, b) => a - b);
204
+ s.p50TTFT = this.percentile(ttftArr, 50);
205
+ s.p95TTFT = this.percentile(ttftArr, 95);
206
+ s.p99TTFT = this.percentile(ttftArr, 99);
207
+ const latArr = [...(this.latencySamples.get(model) ?? [])].sort((a, b) => a - b);
208
+ s.p50Latency = this.percentile(latArr, 50);
209
+ s.p95Latency = this.percentile(latArr, 95);
210
+ s.p99Latency = this.percentile(latArr, 99);
211
+ // 模型级缓存命中率
212
+ const denom = s.totalInput + s.totalCacheRead;
213
+ s.cacheHitRate = denom > 0 ? (s.totalCacheRead / denom) * 100 : null;
214
+ // 累加加权命中率(按请求数加权)
215
+ if (s.cacheHitRate !== null) {
216
+ weightedHitSum += s.cacheHitRate * s.requestCount;
217
+ totalReqForHit += s.requestCount;
218
+ }
145
219
  }
220
+ const weightedCacheHitRate = totalReqForHit > 0 ? weightedHitSum / totalReqForHit : null;
146
221
  return {
147
222
  models: Object.fromEntries(this.statsMap),
148
- totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost },
223
+ totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost, weightedCacheHitRate },
149
224
  };
150
225
  }
151
226
  readLogs(last = 50) {
@@ -174,6 +249,40 @@ class PerfTracker {
174
249
  reset() {
175
250
  this.firstPartTimes.clear();
176
251
  this.statsMap.clear();
252
+ this.ttftSamples.clear();
253
+ this.latencySamples.clear();
254
+ }
255
+ loadSession(sessionID) {
256
+ this.firstPartTimes.clear();
257
+ this.statsMap.clear();
258
+ this.ttftSamples.clear();
259
+ this.latencySamples.clear();
260
+ if (!sessionID)
261
+ return;
262
+ try {
263
+ if (!existsSync(LOG_PATH))
264
+ return;
265
+ const content = readFileSync(LOG_PATH, "utf-8").trim();
266
+ if (!content)
267
+ return;
268
+ const lines = content.split("\n");
269
+ for (const line of lines) {
270
+ if (!line)
271
+ continue;
272
+ try {
273
+ const entry = JSON.parse(line);
274
+ if (entry.sessionID === sessionID) {
275
+ this.updateStats(entry.model, entry);
276
+ }
277
+ }
278
+ catch {
279
+ // Skip malformed lines
280
+ }
281
+ }
282
+ }
283
+ catch {
284
+ // Non-critical loading failure
285
+ }
177
286
  }
178
287
  }
179
288
  export function createPerfTracker() {
package/dist/queries.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DailyBreakdownItem, ModelBreakdownItem, ProviderBreakdownItem, SessionBreakdownItem, SessionTokenData, UsageFilters, UsageReport } from "./formatter.js";
1
+ import type { DailyBreakdownItem, ErrorStats, ModelBreakdownItem, ProviderBreakdownItem, SessionBreakdownItem, SessionTokenData, UsageFilters, UsageReport } from "./formatter.js";
2
2
  export declare function getPresetRange(preset: "all" | "7d" | "30d" | "month"): Pick<UsageFilters, "startDate" | "endDate">;
3
3
  export declare function getCurrentSessionStats(sessionId?: string): Promise<SessionTokenData>;
4
4
  export declare function getSummary(filters?: UsageFilters): Promise<SessionTokenData>;
@@ -8,5 +8,7 @@ export declare function getDailyBreakdown(filters?: UsageFilters): Promise<Daily
8
8
  export declare function getSessionBreakdown(filters?: UsageFilters): Promise<SessionBreakdownItem[]>;
9
9
  export declare function getAvailableModels(): Promise<string[]>;
10
10
  export declare function getAvailableProviders(): Promise<string[]>;
11
+ /** 失败请求计数 SQL。1次运行获取成功数+失败数+按模型细分 */
12
+ export declare function getErrorStats(filters?: UsageFilters): Promise<ErrorStats>;
11
13
  export declare function getUsageReport(filters?: UsageFilters): Promise<UsageReport>;
12
14
  export declare function exportReportAsCsv(report: UsageReport, section: "models" | "providers" | "daily" | "sessions"): string;
package/dist/queries.js CHANGED
@@ -20,6 +20,10 @@ async function queryDb(sql) {
20
20
  function escapeSql(value) {
21
21
  return value.replace(/'/g, "''");
22
22
  }
23
+ /** 校验日期格式必须为 YYYY-MM-DD,防止格式异常字符串进入 SQL */
24
+ function isValidDate(s) {
25
+ return /^\d{4}-\d{2}-\d{2}$/.test(s);
26
+ }
23
27
  function messageWhere(filters) {
24
28
  const where = [
25
29
  "json_extract(m.data, '$.role') = 'assistant'",
@@ -31,10 +35,13 @@ function messageWhere(filters) {
31
35
  where.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
32
36
  if (filters.model)
33
37
  where.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
34
- if (filters.startDate)
35
- where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${escapeSql(filters.startDate)}'`);
36
- if (filters.endDate)
37
- where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${escapeSql(filters.endDate)}'`);
38
+ // Risk fix: 日期参数先验证格式(YYYY-MM-DD),格式不符则忽略该过滤条件
39
+ if (filters.startDate && isValidDate(filters.startDate)) {
40
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
41
+ }
42
+ if (filters.endDate && isValidDate(filters.endDate)) {
43
+ where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
44
+ }
38
45
  return where.join(" AND ");
39
46
  }
40
47
  function parseList(value) {
@@ -254,15 +261,67 @@ ORDER BY value ASC
254
261
  const rows = await queryDb(sql);
255
262
  return rows.map((row) => row.value ?? "unknown");
256
263
  }
264
+ /** 失败请求计数 SQL。1次运行获取成功数+失败数+按模型细分 */
265
+ export async function getErrorStats(filters = {}) {
266
+ // 构建日期/Session/Provider/Model 过滤条件(不包含 tokens.total > 0 过滤)
267
+ const baseConds = [
268
+ "json_extract(m.data, '$.role') = 'assistant'",
269
+ ];
270
+ if (filters.sessionId)
271
+ baseConds.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
272
+ if (filters.provider)
273
+ baseConds.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
274
+ if (filters.model)
275
+ baseConds.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
276
+ if (filters.startDate && isValidDate(filters.startDate)) {
277
+ baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
278
+ }
279
+ if (filters.endDate && isValidDate(filters.endDate)) {
280
+ baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
281
+ }
282
+ const baseWhere = baseConds.join(" AND ");
283
+ // 按模型细化:同时统计成功和失败请求
284
+ const sql = `
285
+ SELECT
286
+ coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
287
+ coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
288
+ count(*) as total,
289
+ sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
290
+ FROM message m
291
+ WHERE ${baseWhere}
292
+ GROUP BY provider, model
293
+ ORDER BY failed DESC
294
+ `.trim();
295
+ try {
296
+ const rows = await queryDb(sql);
297
+ let successCount = 0, failedCount = 0;
298
+ const byModel = rows.map(r => {
299
+ const total = r.total ?? 0;
300
+ const failed = r.failed ?? 0;
301
+ const success = total - failed;
302
+ successCount += success;
303
+ failedCount += failed;
304
+ return { provider: r.provider ?? 'unknown', model: r.model ?? 'unknown', failed, total };
305
+ });
306
+ const errorRate = (successCount + failedCount) > 0
307
+ ? failedCount / (successCount + failedCount)
308
+ : 0;
309
+ return { successCount, failedCount, errorRate, byModel };
310
+ }
311
+ catch {
312
+ return { successCount: 0, failedCount: 0, errorRate: 0, byModel: [] };
313
+ }
314
+ }
257
315
  export async function getUsageReport(filters = {}) {
258
- const [summary, models, providers, daily, sessions] = await Promise.all([
316
+ const [summary, models, providers, daily, sessions, errors] = await Promise.all([
259
317
  getSummary(filters),
260
318
  getModelBreakdown(filters),
261
319
  getProviderBreakdown(filters),
262
320
  getDailyBreakdown(filters),
263
321
  getSessionBreakdown(filters),
322
+ getErrorStats(filters),
264
323
  ]);
265
- return { filters, summary, models, providers, daily, sessions };
324
+ return { filters, summary, models, providers, daily, sessions, errors };
266
325
  }
267
326
  function csvEscape(value) {
268
327
  const text = String(value);
package/dist/sidebar.d.ts CHANGED
@@ -15,8 +15,8 @@ interface TokenWatchPanelProps {
15
15
  api: TuiPluginApi;
16
16
  theme: TuiTheme;
17
17
  perfTracker: PerfTracker;
18
- messages: readonly any[];
19
- allTokenMessages: TokenMessage[];
18
+ messages: () => readonly any[];
19
+ allTokenMessages: () => TokenMessage[];
20
20
  }
21
21
  export declare function TokenWatchPanel(props: TokenWatchPanelProps): any;
22
22
  export {};