@narumitw/pi-analytics 0.45.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 +21 -0
- package/README.md +205 -0
- package/package.json +49 -0
- package/src/analytics.ts +406 -0
- package/src/collector.ts +297 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +1 -0
- package/src/menu.ts +372 -0
- package/src/skills.ts +83 -0
- package/src/storage/database.ts +126 -0
- package/src/storage/migrations.ts +257 -0
- package/src/storage/queries.ts +313 -0
- package/src/storage/store.ts +249 -0
- package/src/types.ts +102 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { Database } from "@tursodatabase/database";
|
|
2
|
+
import type { ProviderErrorCategory } from "../types.js";
|
|
3
|
+
|
|
4
|
+
export type TimeRangeId = "today" | "7d" | "30d" | "all";
|
|
5
|
+
export interface TimeRange {
|
|
6
|
+
id?: TimeRangeId;
|
|
7
|
+
fromMs: number;
|
|
8
|
+
toMs: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface OverviewStats {
|
|
12
|
+
responseCycles: number;
|
|
13
|
+
llmCalls: number;
|
|
14
|
+
callsPerResponse: number;
|
|
15
|
+
p95CallsPerResponse: number;
|
|
16
|
+
toolCalls: number;
|
|
17
|
+
toolErrors: number;
|
|
18
|
+
skillActivations: number;
|
|
19
|
+
providerErrors: number;
|
|
20
|
+
recoveredErrors: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ModelCount {
|
|
24
|
+
provider?: string;
|
|
25
|
+
model?: string;
|
|
26
|
+
count: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SkillStats {
|
|
30
|
+
name: string;
|
|
31
|
+
count: number;
|
|
32
|
+
modelInitiated: number;
|
|
33
|
+
userInitiated: number;
|
|
34
|
+
lastOccurredAtMs: number;
|
|
35
|
+
models: ModelCount[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToolStats {
|
|
39
|
+
name: string;
|
|
40
|
+
count: number;
|
|
41
|
+
errors: number;
|
|
42
|
+
averageDurationMs: number;
|
|
43
|
+
lastOccurredAtMs: number;
|
|
44
|
+
models: ModelCount[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ReliabilityStats {
|
|
48
|
+
http429: number;
|
|
49
|
+
http5xx: number;
|
|
50
|
+
recovered: number;
|
|
51
|
+
terminal: number;
|
|
52
|
+
categories: Record<ProviderErrorCategory, number>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ResponseStats {
|
|
56
|
+
count: number;
|
|
57
|
+
llmCalls: number;
|
|
58
|
+
average: number;
|
|
59
|
+
median: number;
|
|
60
|
+
p95: number;
|
|
61
|
+
maximum: number;
|
|
62
|
+
distribution: { one: number; twoToThree: number; fourToSix: number; sevenPlus: number };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AnalyticsSnapshot {
|
|
66
|
+
overview: OverviewStats;
|
|
67
|
+
skills: SkillStats[];
|
|
68
|
+
tools: ToolStats[];
|
|
69
|
+
reliability: ReliabilityStats;
|
|
70
|
+
responses: ResponseStats;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
74
|
+
const ERROR_CATEGORIES: readonly ProviderErrorCategory[] = [
|
|
75
|
+
"dns",
|
|
76
|
+
"timeout",
|
|
77
|
+
"connection_refused",
|
|
78
|
+
"connection_reset",
|
|
79
|
+
"tls",
|
|
80
|
+
"network_other",
|
|
81
|
+
"provider_other",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
export function resolveTimeRange(id: TimeRangeId, now = Date.now()): TimeRange {
|
|
85
|
+
let fromMs = 0;
|
|
86
|
+
if (id === "today") {
|
|
87
|
+
const date = new Date(now);
|
|
88
|
+
fromMs = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
|
89
|
+
} else if (id === "7d") fromMs = now - 7 * DAY_MS;
|
|
90
|
+
else if (id === "30d") fromMs = now - 30 * DAY_MS;
|
|
91
|
+
return { id, fromMs, toMs: now + 1 };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function querySnapshot(
|
|
95
|
+
database: Database,
|
|
96
|
+
range: TimeRange,
|
|
97
|
+
): Promise<AnalyticsSnapshot> {
|
|
98
|
+
const [runs, skillRows, toolRows, categoryRows, statusRows] = await Promise.all([
|
|
99
|
+
database.all(
|
|
100
|
+
`SELECT generation_count, tool_call_count, tool_error_count,
|
|
101
|
+
skill_activation_count, provider_error_count, recovered_error_count
|
|
102
|
+
FROM response_runs
|
|
103
|
+
WHERE started_at_ms >= ? AND started_at_ms < ?
|
|
104
|
+
ORDER BY generation_count`,
|
|
105
|
+
range.fromMs,
|
|
106
|
+
range.toMs,
|
|
107
|
+
),
|
|
108
|
+
database.all(
|
|
109
|
+
`SELECT s.skill_name, s.initiated_by, s.provider, s.model,
|
|
110
|
+
COUNT(*) AS count, MAX(s.occurred_at_ms) AS last_at
|
|
111
|
+
FROM skill_activations s
|
|
112
|
+
JOIN response_runs r ON r.id = s.run_id
|
|
113
|
+
WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
|
|
114
|
+
GROUP BY s.skill_name, s.initiated_by, s.provider, s.model`,
|
|
115
|
+
range.fromMs,
|
|
116
|
+
range.toMs,
|
|
117
|
+
),
|
|
118
|
+
database.all(
|
|
119
|
+
`SELECT t.tool_name, t.provider, t.model, COUNT(*) AS count,
|
|
120
|
+
SUM(t.is_error) AS errors, AVG(COALESCE(t.duration_ms, 0)) AS average_duration,
|
|
121
|
+
MAX(t.started_at_ms) AS last_at
|
|
122
|
+
FROM tool_calls t
|
|
123
|
+
JOIN response_runs r ON r.id = t.run_id
|
|
124
|
+
WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
|
|
125
|
+
GROUP BY t.tool_name, t.provider, t.model`,
|
|
126
|
+
range.fromMs,
|
|
127
|
+
range.toMs,
|
|
128
|
+
),
|
|
129
|
+
database.all(
|
|
130
|
+
`SELECT e.category, COUNT(*) AS count, SUM(e.terminal) AS terminal
|
|
131
|
+
FROM provider_errors e
|
|
132
|
+
JOIN response_runs r ON r.id = e.run_id
|
|
133
|
+
WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
|
|
134
|
+
GROUP BY e.category`,
|
|
135
|
+
range.fromMs,
|
|
136
|
+
range.toMs,
|
|
137
|
+
),
|
|
138
|
+
database.all(
|
|
139
|
+
`SELECT p.status, COUNT(*) AS count
|
|
140
|
+
FROM provider_responses p
|
|
141
|
+
JOIN model_generations g ON g.id = p.generation_id
|
|
142
|
+
JOIN response_runs r ON r.id = g.run_id
|
|
143
|
+
WHERE r.started_at_ms >= ? AND r.started_at_ms < ?
|
|
144
|
+
GROUP BY p.status`,
|
|
145
|
+
range.fromMs,
|
|
146
|
+
range.toMs,
|
|
147
|
+
),
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const generationCounts = runs.map((row) => numberValue(row.generation_count));
|
|
151
|
+
const responseStats = responseStatistics(generationCounts);
|
|
152
|
+
const overview: OverviewStats = {
|
|
153
|
+
responseCycles: runs.length,
|
|
154
|
+
llmCalls: sum(generationCounts),
|
|
155
|
+
callsPerResponse: responseStats.average,
|
|
156
|
+
p95CallsPerResponse: responseStats.p95,
|
|
157
|
+
toolCalls: sum(runs.map((row) => numberValue(row.tool_call_count))),
|
|
158
|
+
toolErrors: sum(runs.map((row) => numberValue(row.tool_error_count))),
|
|
159
|
+
skillActivations: sum(runs.map((row) => numberValue(row.skill_activation_count))),
|
|
160
|
+
providerErrors: sum(runs.map((row) => numberValue(row.provider_error_count))),
|
|
161
|
+
recoveredErrors: sum(runs.map((row) => numberValue(row.recovered_error_count))),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const categories = Object.fromEntries(
|
|
165
|
+
ERROR_CATEGORIES.map((category) => [category, 0]),
|
|
166
|
+
) as Record<ProviderErrorCategory, number>;
|
|
167
|
+
let terminal = 0;
|
|
168
|
+
for (const row of categoryRows) {
|
|
169
|
+
const category = String(row.category) as ProviderErrorCategory;
|
|
170
|
+
if (category in categories) categories[category] = numberValue(row.count);
|
|
171
|
+
terminal += numberValue(row.terminal);
|
|
172
|
+
}
|
|
173
|
+
let http429 = 0;
|
|
174
|
+
let http5xx = 0;
|
|
175
|
+
for (const row of statusRows) {
|
|
176
|
+
const status = numberValue(row.status);
|
|
177
|
+
if (status === 429) http429 += numberValue(row.count);
|
|
178
|
+
if (status >= 500 && status < 600) http5xx += numberValue(row.count);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
overview,
|
|
183
|
+
skills: foldSkills(skillRows),
|
|
184
|
+
tools: foldTools(toolRows),
|
|
185
|
+
reliability: {
|
|
186
|
+
http429,
|
|
187
|
+
http5xx,
|
|
188
|
+
recovered: overview.recoveredErrors,
|
|
189
|
+
terminal,
|
|
190
|
+
categories,
|
|
191
|
+
},
|
|
192
|
+
responses: responseStats,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function foldSkills(rows: Array<Record<string, unknown>>): SkillStats[] {
|
|
197
|
+
const result = new Map<string, SkillStats>();
|
|
198
|
+
for (const row of rows) {
|
|
199
|
+
const name = String(row.skill_name);
|
|
200
|
+
const count = numberValue(row.count);
|
|
201
|
+
const item = result.get(name) ?? {
|
|
202
|
+
name,
|
|
203
|
+
count: 0,
|
|
204
|
+
modelInitiated: 0,
|
|
205
|
+
userInitiated: 0,
|
|
206
|
+
lastOccurredAtMs: 0,
|
|
207
|
+
models: [],
|
|
208
|
+
};
|
|
209
|
+
item.count += count;
|
|
210
|
+
if (row.initiated_by === "user") item.userInitiated += count;
|
|
211
|
+
else item.modelInitiated += count;
|
|
212
|
+
item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
|
|
213
|
+
mergeModelCount(item.models, {
|
|
214
|
+
provider: optionalString(row.provider),
|
|
215
|
+
model: optionalString(row.model),
|
|
216
|
+
count,
|
|
217
|
+
});
|
|
218
|
+
result.set(name, item);
|
|
219
|
+
}
|
|
220
|
+
return [...result.values()]
|
|
221
|
+
.map((item) => ({ ...item, models: sortModels(item.models) }))
|
|
222
|
+
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function foldTools(rows: Array<Record<string, unknown>>): ToolStats[] {
|
|
226
|
+
const result = new Map<string, ToolStats & { weightedDuration: number }>();
|
|
227
|
+
for (const row of rows) {
|
|
228
|
+
const name = String(row.tool_name);
|
|
229
|
+
const count = numberValue(row.count);
|
|
230
|
+
const average = numberValue(row.average_duration);
|
|
231
|
+
const item = result.get(name) ?? {
|
|
232
|
+
name,
|
|
233
|
+
count: 0,
|
|
234
|
+
errors: 0,
|
|
235
|
+
averageDurationMs: 0,
|
|
236
|
+
weightedDuration: 0,
|
|
237
|
+
lastOccurredAtMs: 0,
|
|
238
|
+
models: [],
|
|
239
|
+
};
|
|
240
|
+
item.count += count;
|
|
241
|
+
item.errors += numberValue(row.errors);
|
|
242
|
+
item.weightedDuration += average * count;
|
|
243
|
+
item.averageDurationMs = item.count > 0 ? item.weightedDuration / item.count : 0;
|
|
244
|
+
item.lastOccurredAtMs = Math.max(item.lastOccurredAtMs, numberValue(row.last_at));
|
|
245
|
+
mergeModelCount(item.models, {
|
|
246
|
+
provider: optionalString(row.provider),
|
|
247
|
+
model: optionalString(row.model),
|
|
248
|
+
count,
|
|
249
|
+
});
|
|
250
|
+
result.set(name, item);
|
|
251
|
+
}
|
|
252
|
+
return [...result.values()]
|
|
253
|
+
.map(({ weightedDuration: _, ...item }) => ({ ...item, models: sortModels(item.models) }))
|
|
254
|
+
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function responseStatistics(generationCounts: number[]): ResponseStats {
|
|
258
|
+
const sorted = [...generationCounts].sort((left, right) => left - right);
|
|
259
|
+
const count = sorted.length;
|
|
260
|
+
const llmCalls = sum(sorted);
|
|
261
|
+
const nearestRank = (percentile: number) =>
|
|
262
|
+
count === 0 ? 0 : (sorted[Math.max(0, Math.ceil(percentile * count) - 1)] ?? 0);
|
|
263
|
+
const median =
|
|
264
|
+
count === 0
|
|
265
|
+
? 0
|
|
266
|
+
: count % 2 === 1
|
|
267
|
+
? (sorted[Math.floor(count / 2)] ?? 0)
|
|
268
|
+
: ((sorted[count / 2 - 1] ?? 0) + (sorted[count / 2] ?? 0)) / 2;
|
|
269
|
+
return {
|
|
270
|
+
count,
|
|
271
|
+
llmCalls,
|
|
272
|
+
average: count > 0 ? llmCalls / count : 0,
|
|
273
|
+
median,
|
|
274
|
+
p95: nearestRank(0.95),
|
|
275
|
+
maximum: sorted.at(-1) ?? 0,
|
|
276
|
+
distribution: {
|
|
277
|
+
one: sorted.filter((value) => value === 1).length,
|
|
278
|
+
twoToThree: sorted.filter((value) => value >= 2 && value <= 3).length,
|
|
279
|
+
fourToSix: sorted.filter((value) => value >= 4 && value <= 6).length,
|
|
280
|
+
sevenPlus: sorted.filter((value) => value >= 7).length,
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function mergeModelCount(models: ModelCount[], next: ModelCount): void {
|
|
286
|
+
const existing = models.find(
|
|
287
|
+
({ provider, model }) => provider === next.provider && model === next.model,
|
|
288
|
+
);
|
|
289
|
+
if (existing) existing.count += next.count;
|
|
290
|
+
else models.push(next);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function sortModels(models: ModelCount[]): ModelCount[] {
|
|
294
|
+
return models.sort(
|
|
295
|
+
(left, right) =>
|
|
296
|
+
right.count - left.count ||
|
|
297
|
+
`${left.provider ?? ""}/${left.model ?? ""}`.localeCompare(
|
|
298
|
+
`${right.provider ?? ""}/${right.model ?? ""}`,
|
|
299
|
+
),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function optionalString(value: unknown): string | undefined {
|
|
304
|
+
return typeof value === "string" ? value : undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function numberValue(value: unknown): number {
|
|
308
|
+
return typeof value === "number" && Number.isFinite(value) ? value : Number(value) || 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sum(values: readonly number[]): number {
|
|
312
|
+
return values.reduce((total, value) => total + value, 0);
|
|
313
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { Database, Transaction } from "@tursodatabase/database";
|
|
2
|
+
import type { SettledRun } from "../types.js";
|
|
3
|
+
import type { OpenedAnalyticsDatabase } from "./database.js";
|
|
4
|
+
import type { AnalyticsSnapshot, TimeRange } from "./queries.js";
|
|
5
|
+
import { querySnapshot } from "./queries.js";
|
|
6
|
+
|
|
7
|
+
const MAX_PENDING_RUNS = 100;
|
|
8
|
+
const WRITE_ATTEMPTS = 6;
|
|
9
|
+
|
|
10
|
+
export class AnalyticsStore {
|
|
11
|
+
private readonly pending: SettledRun[] = [];
|
|
12
|
+
private readonly activeQueries = new Set<Promise<unknown>>();
|
|
13
|
+
private readonly query: typeof querySnapshot;
|
|
14
|
+
private mutationTail: Promise<void> = Promise.resolve();
|
|
15
|
+
private closed = false;
|
|
16
|
+
private closePromise: Promise<void> | undefined;
|
|
17
|
+
|
|
18
|
+
constructor(
|
|
19
|
+
private readonly opened: OpenedAnalyticsDatabase,
|
|
20
|
+
dependencies: { querySnapshot?: typeof querySnapshot } = {},
|
|
21
|
+
) {
|
|
22
|
+
this.query = dependencies.querySnapshot ?? querySnapshot;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get path(): string {
|
|
26
|
+
return this.opened.path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordRun(run: SettledRun): Promise<void> {
|
|
30
|
+
if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
|
|
31
|
+
if (this.pending.length >= MAX_PENDING_RUNS) this.pending.shift();
|
|
32
|
+
this.pending.push(run);
|
|
33
|
+
return this.enqueueMutation(() => this.flushPending());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getSnapshot(range: TimeRange): Promise<AnalyticsSnapshot> {
|
|
37
|
+
if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
|
|
38
|
+
const query = (async () => {
|
|
39
|
+
await this.mutationTail;
|
|
40
|
+
return this.query(this.opened.connection, range);
|
|
41
|
+
})();
|
|
42
|
+
this.activeQueries.add(query);
|
|
43
|
+
void query.finally(() => this.activeQueries.delete(query)).catch(() => undefined);
|
|
44
|
+
return query;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
clearAll(): Promise<number> {
|
|
48
|
+
if (this.closed) return Promise.reject(new Error("Analytics store is closed."));
|
|
49
|
+
let deleted = 0;
|
|
50
|
+
return this.enqueueMutation(async () => {
|
|
51
|
+
await withWriteRetry(async () => {
|
|
52
|
+
const transaction = this.opened.connection.transactionAsync(async (tx) => {
|
|
53
|
+
const row = (await tx.get("SELECT COUNT(*) AS count FROM response_runs")) as {
|
|
54
|
+
count?: number;
|
|
55
|
+
};
|
|
56
|
+
deleted = Number(row?.count ?? 0);
|
|
57
|
+
for (const table of [
|
|
58
|
+
"provider_responses",
|
|
59
|
+
"provider_errors",
|
|
60
|
+
"tool_calls",
|
|
61
|
+
"skill_activations",
|
|
62
|
+
"model_generations",
|
|
63
|
+
"response_runs",
|
|
64
|
+
]) {
|
|
65
|
+
await tx.exec(`DELETE FROM ${table}`);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await transaction.immediate();
|
|
69
|
+
});
|
|
70
|
+
}).then(() => deleted);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
close(): Promise<void> {
|
|
74
|
+
if (this.closePromise) return this.closePromise;
|
|
75
|
+
this.closed = true;
|
|
76
|
+
this.closePromise = (async () => {
|
|
77
|
+
await this.mutationTail.catch(() => undefined);
|
|
78
|
+
await Promise.allSettled([...this.activeQueries]);
|
|
79
|
+
let pendingError: unknown;
|
|
80
|
+
try {
|
|
81
|
+
await this.flushPending();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
pendingError = error;
|
|
84
|
+
this.pending.length = 0;
|
|
85
|
+
}
|
|
86
|
+
await this.opened.close();
|
|
87
|
+
if (pendingError !== undefined) {
|
|
88
|
+
throw new Error("Analytics pending writes could not be saved before close.", {
|
|
89
|
+
cause: pendingError,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
return this.closePromise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private enqueueMutation(operation: () => Promise<void>): Promise<void> {
|
|
97
|
+
const result = this.mutationTail.then(operation);
|
|
98
|
+
this.mutationTail = result.catch(() => undefined);
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private async flushPending(): Promise<void> {
|
|
103
|
+
while (this.pending.length > 0) {
|
|
104
|
+
const run = this.pending[0];
|
|
105
|
+
if (!run) return;
|
|
106
|
+
await withWriteRetry(() => writeRun(this.opened.connection, run));
|
|
107
|
+
this.pending.shift();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function writeRun(database: Database, run: SettledRun): Promise<void> {
|
|
113
|
+
const transaction = database.transactionAsync(async (tx) => {
|
|
114
|
+
const existing = await tx.get("SELECT id FROM response_runs WHERE id = ?", run.id);
|
|
115
|
+
if (existing) return;
|
|
116
|
+
await insertRun(tx, run);
|
|
117
|
+
for (const generation of run.generations) {
|
|
118
|
+
await tx.run(
|
|
119
|
+
`INSERT INTO model_generations(
|
|
120
|
+
id, run_id, ordinal, provider, model, thinking_level, started_at_ms,
|
|
121
|
+
finished_at_ms, duration_ms, stop_reason, outcome
|
|
122
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
123
|
+
generation.id,
|
|
124
|
+
run.id,
|
|
125
|
+
generation.ordinal,
|
|
126
|
+
generation.provider ?? null,
|
|
127
|
+
generation.model ?? null,
|
|
128
|
+
generation.thinkingLevel ?? null,
|
|
129
|
+
generation.startedAtMs,
|
|
130
|
+
generation.finishedAtMs ?? null,
|
|
131
|
+
generation.durationMs ?? null,
|
|
132
|
+
generation.stopReason ?? null,
|
|
133
|
+
generation.outcome,
|
|
134
|
+
);
|
|
135
|
+
for (const response of generation.responses) {
|
|
136
|
+
await tx.run(
|
|
137
|
+
`INSERT INTO provider_responses(generation_id, ordinal, occurred_at_ms, status)
|
|
138
|
+
VALUES (?, ?, ?, ?)`,
|
|
139
|
+
generation.id,
|
|
140
|
+
response.ordinal,
|
|
141
|
+
response.occurredAtMs,
|
|
142
|
+
response.status,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const tool of run.tools) {
|
|
147
|
+
await tx.run(
|
|
148
|
+
`INSERT INTO tool_calls(
|
|
149
|
+
id, run_id, ordinal, tool_name, provider, model, started_at_ms,
|
|
150
|
+
finished_at_ms, duration_ms, is_error, completion_state
|
|
151
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
152
|
+
tool.id,
|
|
153
|
+
run.id,
|
|
154
|
+
tool.ordinal,
|
|
155
|
+
tool.name,
|
|
156
|
+
tool.provider ?? null,
|
|
157
|
+
tool.model ?? null,
|
|
158
|
+
tool.startedAtMs,
|
|
159
|
+
tool.finishedAtMs ?? null,
|
|
160
|
+
tool.durationMs ?? null,
|
|
161
|
+
tool.isError ? 1 : 0,
|
|
162
|
+
tool.completionState,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
for (const skill of run.skills) {
|
|
166
|
+
await tx.run(
|
|
167
|
+
`INSERT INTO skill_activations(
|
|
168
|
+
id, run_id, occurred_at_ms, skill_name, initiated_by, provider, model
|
|
169
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
170
|
+
skill.id,
|
|
171
|
+
run.id,
|
|
172
|
+
skill.occurredAtMs,
|
|
173
|
+
skill.name,
|
|
174
|
+
skill.initiatedBy,
|
|
175
|
+
skill.provider ?? null,
|
|
176
|
+
skill.model ?? null,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
for (const error of run.providerErrors) {
|
|
180
|
+
await tx.run(
|
|
181
|
+
`INSERT INTO provider_errors(
|
|
182
|
+
id, run_id, generation_id, occurred_at_ms, provider, model,
|
|
183
|
+
category, recovered, terminal
|
|
184
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
185
|
+
error.id,
|
|
186
|
+
run.id,
|
|
187
|
+
error.generationId ?? null,
|
|
188
|
+
error.occurredAtMs,
|
|
189
|
+
error.provider ?? null,
|
|
190
|
+
error.model ?? null,
|
|
191
|
+
error.category,
|
|
192
|
+
error.recovered ? 1 : 0,
|
|
193
|
+
error.terminal ? 1 : 0,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
await transaction.immediate();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function insertRun(tx: Transaction, run: SettledRun): Promise<unknown> {
|
|
201
|
+
return tx.run(
|
|
202
|
+
`INSERT INTO response_runs(
|
|
203
|
+
id, started_at_ms, finished_at_ms, duration_ms, trigger_source,
|
|
204
|
+
initial_provider, initial_model, outcome, attempt_count, generation_count,
|
|
205
|
+
tool_call_count, tool_error_count, skill_activation_count,
|
|
206
|
+
provider_error_count, recovered_error_count
|
|
207
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
208
|
+
run.id,
|
|
209
|
+
run.startedAtMs,
|
|
210
|
+
run.finishedAtMs,
|
|
211
|
+
run.durationMs,
|
|
212
|
+
run.triggerSource,
|
|
213
|
+
run.initialProvider ?? null,
|
|
214
|
+
run.initialModel ?? null,
|
|
215
|
+
run.outcome,
|
|
216
|
+
run.attemptCount,
|
|
217
|
+
run.generations.length,
|
|
218
|
+
run.tools.length,
|
|
219
|
+
run.toolErrorCount,
|
|
220
|
+
run.skills.length,
|
|
221
|
+
run.providerErrorCount,
|
|
222
|
+
run.recoveredErrorCount,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function withWriteRetry(operation: () => Promise<void>): Promise<void> {
|
|
227
|
+
let lastError: unknown;
|
|
228
|
+
for (let attempt = 0; attempt < WRITE_ATTEMPTS; attempt += 1) {
|
|
229
|
+
try {
|
|
230
|
+
await operation();
|
|
231
|
+
return;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
lastError = error;
|
|
234
|
+
if (!isConflict(error) || attempt + 1 >= WRITE_ATTEMPTS) throw error;
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 5 * (attempt + 1)));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
throw lastError;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isConflict(error: unknown): boolean {
|
|
242
|
+
const message =
|
|
243
|
+
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
244
|
+
return (
|
|
245
|
+
message.includes("statement was interrupted") ||
|
|
246
|
+
message.includes("database is locked") ||
|
|
247
|
+
message.includes("database is busy")
|
|
248
|
+
);
|
|
249
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export type TriggerSource = "interactive" | "rpc" | "extension" | "unknown";
|
|
2
|
+
export type RunOutcome =
|
|
3
|
+
| "success"
|
|
4
|
+
| "recovered_success"
|
|
5
|
+
| "error"
|
|
6
|
+
| "aborted"
|
|
7
|
+
| "length"
|
|
8
|
+
| "interrupted";
|
|
9
|
+
export type GenerationOutcome =
|
|
10
|
+
| "pending"
|
|
11
|
+
| "stop"
|
|
12
|
+
| "tool_use"
|
|
13
|
+
| "error"
|
|
14
|
+
| "aborted"
|
|
15
|
+
| "length"
|
|
16
|
+
| "interrupted";
|
|
17
|
+
export type ProviderErrorCategory =
|
|
18
|
+
| "dns"
|
|
19
|
+
| "timeout"
|
|
20
|
+
| "connection_refused"
|
|
21
|
+
| "connection_reset"
|
|
22
|
+
| "tls"
|
|
23
|
+
| "network_other"
|
|
24
|
+
| "provider_other";
|
|
25
|
+
|
|
26
|
+
export interface ModelIdentity {
|
|
27
|
+
provider: string;
|
|
28
|
+
model: string;
|
|
29
|
+
thinkingLevel?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ProviderResponseRecord {
|
|
33
|
+
ordinal: number;
|
|
34
|
+
occurredAtMs: number;
|
|
35
|
+
status: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GenerationRecord {
|
|
39
|
+
id: string;
|
|
40
|
+
ordinal: number;
|
|
41
|
+
provider?: string;
|
|
42
|
+
model?: string;
|
|
43
|
+
thinkingLevel?: string;
|
|
44
|
+
startedAtMs: number;
|
|
45
|
+
finishedAtMs?: number;
|
|
46
|
+
durationMs?: number;
|
|
47
|
+
stopReason?: string;
|
|
48
|
+
outcome: GenerationOutcome;
|
|
49
|
+
responses: ProviderResponseRecord[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ToolCallRecord {
|
|
53
|
+
id: string;
|
|
54
|
+
ordinal: number;
|
|
55
|
+
name: string;
|
|
56
|
+
provider?: string;
|
|
57
|
+
model?: string;
|
|
58
|
+
startedAtMs: number;
|
|
59
|
+
finishedAtMs?: number;
|
|
60
|
+
durationMs?: number;
|
|
61
|
+
isError: boolean;
|
|
62
|
+
completionState: "running" | "finished" | "interrupted";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SkillActivationRecord {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
initiatedBy: "user" | "model";
|
|
69
|
+
occurredAtMs: number;
|
|
70
|
+
provider?: string;
|
|
71
|
+
model?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProviderErrorRecord {
|
|
75
|
+
id: string;
|
|
76
|
+
generationId?: string;
|
|
77
|
+
occurredAtMs: number;
|
|
78
|
+
provider?: string;
|
|
79
|
+
model?: string;
|
|
80
|
+
category: ProviderErrorCategory;
|
|
81
|
+
recovered: boolean;
|
|
82
|
+
terminal: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SettledRun {
|
|
86
|
+
id: string;
|
|
87
|
+
startedAtMs: number;
|
|
88
|
+
finishedAtMs: number;
|
|
89
|
+
durationMs: number;
|
|
90
|
+
triggerSource: TriggerSource;
|
|
91
|
+
initialProvider?: string;
|
|
92
|
+
initialModel?: string;
|
|
93
|
+
outcome: RunOutcome;
|
|
94
|
+
attemptCount: number;
|
|
95
|
+
generations: GenerationRecord[];
|
|
96
|
+
tools: ToolCallRecord[];
|
|
97
|
+
skills: SkillActivationRecord[];
|
|
98
|
+
providerErrors: ProviderErrorRecord[];
|
|
99
|
+
toolErrorCount: number;
|
|
100
|
+
providerErrorCount: number;
|
|
101
|
+
recoveredErrorCount: number;
|
|
102
|
+
}
|