@chrysb/alphaclaw 0.4.6-beta.9 → 0.5.1-beta.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/lib/public/js/components/usage-tab/index.js +43 -0
- package/lib/public/js/components/usage-tab/overview-section.js +2 -1
- package/lib/public/js/components/usage-tab/use-usage-tab.js +48 -0
- package/lib/public/js/lib/api.js +14 -0
- package/lib/server/db/usage/backfill.js +416 -0
- package/lib/server/db/usage/index.js +4 -0
- package/lib/server/routes/usage.js +80 -0
- package/lib/server.js +7 -0
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { h } from "https://esm.sh/preact";
|
|
|
2
2
|
import htm from "https://esm.sh/htm";
|
|
3
3
|
import { ActionButton } from "../action-button.js";
|
|
4
4
|
import { PageHeader } from "../page-header.js";
|
|
5
|
+
import { showToast } from "../toast.js";
|
|
5
6
|
import { OverviewSection } from "./overview-section.js";
|
|
6
7
|
import { SessionsSection } from "./sessions-section.js";
|
|
7
8
|
import { useUsageTab } from "./use-usage-tab.js";
|
|
@@ -26,6 +27,20 @@ export const UsageTab = ({ sessionId = "" }) => {
|
|
|
26
27
|
);
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
const handleRunBackfill = async () => {
|
|
31
|
+
try {
|
|
32
|
+
const result = await actions.triggerBackfill();
|
|
33
|
+
const backfilledEvents = Number(result?.backfilledEvents || 0);
|
|
34
|
+
const filesScanned = Number(result?.filesScanned || 0);
|
|
35
|
+
showToast(
|
|
36
|
+
`Imported ${backfilledEvents.toLocaleString()} usage events from ${filesScanned.toLocaleString()} session files`,
|
|
37
|
+
"success",
|
|
38
|
+
);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
showToast(error.message || "Could not import historical usage data", "error");
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
29
44
|
return html`
|
|
30
45
|
<div class="space-y-4">
|
|
31
46
|
<${PageHeader}
|
|
@@ -46,6 +61,34 @@ export const UsageTab = ({ sessionId = "" }) => {
|
|
|
46
61
|
${state.error}
|
|
47
62
|
</div>`
|
|
48
63
|
: null}
|
|
64
|
+
${state.showBackfillBanner
|
|
65
|
+
? html`
|
|
66
|
+
<div class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
67
|
+
<p class="text-xs text-gray-300 leading-5">
|
|
68
|
+
We found historical usage data from
|
|
69
|
+
<span class="font-medium text-gray-200"
|
|
70
|
+
>${Number(state.estimatedBackfillFiles || 0).toLocaleString()}</span
|
|
71
|
+
>
|
|
72
|
+
session files. Import it?
|
|
73
|
+
</p>
|
|
74
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
75
|
+
<${ActionButton}
|
|
76
|
+
onClick=${handleRunBackfill}
|
|
77
|
+
loading=${state.runningBackfill}
|
|
78
|
+
disabled=${state.loadingBackfillStatus}
|
|
79
|
+
idleLabel="Import historical data"
|
|
80
|
+
loadingLabel="Importing..."
|
|
81
|
+
/>
|
|
82
|
+
<${ActionButton}
|
|
83
|
+
onClick=${actions.dismissBackfillBanner}
|
|
84
|
+
tone="secondary"
|
|
85
|
+
disabled=${state.runningBackfill}
|
|
86
|
+
idleLabel="Dismiss"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
`
|
|
91
|
+
: null}
|
|
49
92
|
${state.loadingSummary && !state.summary
|
|
50
93
|
? html`<div class="text-sm text-[var(--text-muted)]">Loading usage summary...</div>`
|
|
51
94
|
: html`
|
|
@@ -31,8 +31,9 @@ const getCacheHitRateValueClass = (ratio) => {
|
|
|
31
31
|
const getOverviewMetrics = (summary) => {
|
|
32
32
|
const totals = summary?.totals || {};
|
|
33
33
|
const cacheReadTokens = Number(totals.cacheReadTokens || 0);
|
|
34
|
+
const cacheWriteTokens = Number(totals.cacheWriteTokens || 0);
|
|
34
35
|
const inputTokens = Number(totals.inputTokens || 0);
|
|
35
|
-
const promptTokens = inputTokens + cacheReadTokens;
|
|
36
|
+
const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
|
|
36
37
|
const turnCount = Number(totals.turnCount || 0);
|
|
37
38
|
const totalTokens = Number(totals.totalTokens || 0);
|
|
38
39
|
const totalCost = Number(totals.totalCost || 0);
|
|
@@ -6,9 +6,11 @@ import {
|
|
|
6
6
|
useState,
|
|
7
7
|
} from "https://esm.sh/preact/hooks";
|
|
8
8
|
import {
|
|
9
|
+
fetchUsageBackfillStatus,
|
|
9
10
|
fetchUsageSessionDetail,
|
|
10
11
|
fetchUsageSessions,
|
|
11
12
|
fetchUsageSummary,
|
|
13
|
+
runUsageBackfill,
|
|
12
14
|
} from "../../lib/api.js";
|
|
13
15
|
import { formatInteger, formatUsd } from "../../lib/format.js";
|
|
14
16
|
import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
|
|
@@ -41,6 +43,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
41
43
|
const [loadingSummary, setLoadingSummary] = useState(false);
|
|
42
44
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
|
43
45
|
const [loadingDetailById, setLoadingDetailById] = useState({});
|
|
46
|
+
const [loadingBackfillStatus, setLoadingBackfillStatus] = useState(false);
|
|
47
|
+
const [runningBackfill, setRunningBackfill] = useState(false);
|
|
48
|
+
const [showBackfillBanner, setShowBackfillBanner] = useState(false);
|
|
49
|
+
const [estimatedBackfillFiles, setEstimatedBackfillFiles] = useState(0);
|
|
44
50
|
const [expandedSessionIds, setExpandedSessionIds] = useState(() =>
|
|
45
51
|
sessionId ? [String(sessionId)] : [],
|
|
46
52
|
);
|
|
@@ -96,6 +102,37 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
96
102
|
}
|
|
97
103
|
}, []);
|
|
98
104
|
|
|
105
|
+
const loadBackfillStatus = useCallback(async () => {
|
|
106
|
+
setLoadingBackfillStatus(true);
|
|
107
|
+
try {
|
|
108
|
+
const data = await fetchUsageBackfillStatus();
|
|
109
|
+
const available = !!data?.available;
|
|
110
|
+
setShowBackfillBanner(available);
|
|
111
|
+
setEstimatedBackfillFiles(Number(data?.estimatedFiles || 0));
|
|
112
|
+
} catch {
|
|
113
|
+
setShowBackfillBanner(false);
|
|
114
|
+
setEstimatedBackfillFiles(0);
|
|
115
|
+
} finally {
|
|
116
|
+
setLoadingBackfillStatus(false);
|
|
117
|
+
}
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const triggerBackfill = useCallback(async () => {
|
|
121
|
+
setRunningBackfill(true);
|
|
122
|
+
try {
|
|
123
|
+
const result = await runUsageBackfill();
|
|
124
|
+
setShowBackfillBanner(false);
|
|
125
|
+
await loadSummary();
|
|
126
|
+
await loadSessions();
|
|
127
|
+
return result;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
setError(err.message || "Could not backfill usage data");
|
|
130
|
+
throw err;
|
|
131
|
+
} finally {
|
|
132
|
+
setRunningBackfill(false);
|
|
133
|
+
}
|
|
134
|
+
}, [loadSessions, loadSummary]);
|
|
135
|
+
|
|
99
136
|
useEffect(() => {
|
|
100
137
|
loadSummary();
|
|
101
138
|
}, [loadSummary]);
|
|
@@ -111,6 +148,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
111
148
|
loadSessions();
|
|
112
149
|
}, [loadSessions]);
|
|
113
150
|
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
loadBackfillStatus();
|
|
153
|
+
}, [loadBackfillStatus]);
|
|
154
|
+
|
|
114
155
|
useEffect(() => {
|
|
115
156
|
const safeSessionId = String(sessionId || "").trim();
|
|
116
157
|
if (!safeSessionId) return;
|
|
@@ -256,6 +297,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
256
297
|
loadingSummary,
|
|
257
298
|
loadingSessions,
|
|
258
299
|
loadingDetailById,
|
|
300
|
+
loadingBackfillStatus,
|
|
301
|
+
runningBackfill,
|
|
302
|
+
showBackfillBanner,
|
|
303
|
+
estimatedBackfillFiles,
|
|
259
304
|
expandedSessionIds,
|
|
260
305
|
error,
|
|
261
306
|
periodSummary,
|
|
@@ -265,6 +310,9 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
265
310
|
setDays,
|
|
266
311
|
setMetric,
|
|
267
312
|
loadSummary,
|
|
313
|
+
loadBackfillStatus,
|
|
314
|
+
triggerBackfill,
|
|
315
|
+
dismissBackfillBanner: () => setShowBackfillBanner(false),
|
|
268
316
|
loadSessionDetail,
|
|
269
317
|
setExpandedSessionIds,
|
|
270
318
|
},
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -345,6 +345,20 @@ export async function fetchUsageSessionTimeSeries(sessionId, maxPoints = 100) {
|
|
|
345
345
|
return parseJsonOrThrow(res, "Could not load usage time series");
|
|
346
346
|
}
|
|
347
347
|
|
|
348
|
+
export async function fetchUsageBackfillStatus() {
|
|
349
|
+
const res = await authFetch("/api/usage/backfill/status");
|
|
350
|
+
return parseJsonOrThrow(res, "Could not load usage backfill status");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export async function runUsageBackfill() {
|
|
354
|
+
const res = await authFetch("/api/usage/backfill", {
|
|
355
|
+
method: "POST",
|
|
356
|
+
headers: { "Content-Type": "application/json" },
|
|
357
|
+
body: JSON.stringify({}),
|
|
358
|
+
});
|
|
359
|
+
return parseJsonOrThrow(res, "Could not backfill usage data");
|
|
360
|
+
}
|
|
361
|
+
|
|
348
362
|
export async function fetchWatchdogEvents(limit = 20) {
|
|
349
363
|
const res = await authFetch(
|
|
350
364
|
`/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`,
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const readline = require("readline");
|
|
4
|
+
|
|
5
|
+
const kSessionsStoreFileName = "sessions.json";
|
|
6
|
+
const kSessionFileSuffix = ".jsonl";
|
|
7
|
+
|
|
8
|
+
const toFiniteNumber = (value) => {
|
|
9
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
10
|
+
if (typeof value === "string" && value.trim()) {
|
|
11
|
+
const parsed = Number(value);
|
|
12
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
13
|
+
}
|
|
14
|
+
return undefined;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const toNonNegativeInt = (value) => {
|
|
18
|
+
const parsed = toFiniteNumber(value);
|
|
19
|
+
if (parsed === undefined) return undefined;
|
|
20
|
+
if (parsed <= 0) return 0;
|
|
21
|
+
return Math.floor(parsed);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const normalizeUsage = (rawUsage) => {
|
|
25
|
+
if (!rawUsage || typeof rawUsage !== "object") return null;
|
|
26
|
+
const inputTokens = toNonNegativeInt(
|
|
27
|
+
rawUsage.input ??
|
|
28
|
+
rawUsage.inputTokens ??
|
|
29
|
+
rawUsage.input_tokens ??
|
|
30
|
+
rawUsage.promptTokens ??
|
|
31
|
+
rawUsage.prompt_tokens,
|
|
32
|
+
);
|
|
33
|
+
const outputTokens = toNonNegativeInt(
|
|
34
|
+
rawUsage.output ??
|
|
35
|
+
rawUsage.outputTokens ??
|
|
36
|
+
rawUsage.output_tokens ??
|
|
37
|
+
rawUsage.completionTokens ??
|
|
38
|
+
rawUsage.completion_tokens,
|
|
39
|
+
);
|
|
40
|
+
const cacheReadTokens = toNonNegativeInt(
|
|
41
|
+
rawUsage.cacheRead ??
|
|
42
|
+
rawUsage.cache_read ??
|
|
43
|
+
rawUsage.cache_read_input_tokens ??
|
|
44
|
+
rawUsage.cached_tokens ??
|
|
45
|
+
rawUsage.prompt_tokens_details?.cached_tokens,
|
|
46
|
+
);
|
|
47
|
+
const cacheWriteTokens = toNonNegativeInt(
|
|
48
|
+
rawUsage.cacheWrite ??
|
|
49
|
+
rawUsage.cache_write ??
|
|
50
|
+
rawUsage.cache_creation_input_tokens,
|
|
51
|
+
);
|
|
52
|
+
const totalTokens = toNonNegativeInt(
|
|
53
|
+
rawUsage.total ?? rawUsage.totalTokens ?? rawUsage.total_tokens,
|
|
54
|
+
);
|
|
55
|
+
if (
|
|
56
|
+
inputTokens === undefined &&
|
|
57
|
+
outputTokens === undefined &&
|
|
58
|
+
cacheReadTokens === undefined &&
|
|
59
|
+
cacheWriteTokens === undefined &&
|
|
60
|
+
totalTokens === undefined
|
|
61
|
+
) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const normalized = {
|
|
66
|
+
inputTokens: inputTokens ?? 0,
|
|
67
|
+
outputTokens: outputTokens ?? 0,
|
|
68
|
+
cacheReadTokens: cacheReadTokens ?? 0,
|
|
69
|
+
cacheWriteTokens: cacheWriteTokens ?? 0,
|
|
70
|
+
totalTokens:
|
|
71
|
+
totalTokens ??
|
|
72
|
+
(inputTokens ?? 0) +
|
|
73
|
+
(outputTokens ?? 0) +
|
|
74
|
+
(cacheReadTokens ?? 0) +
|
|
75
|
+
(cacheWriteTokens ?? 0),
|
|
76
|
+
};
|
|
77
|
+
if (normalized.totalTokens <= 0) return null;
|
|
78
|
+
return normalized;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const parseTimestampMs = (entry) => {
|
|
82
|
+
const rawTimestamp = toFiniteNumber(entry?.timestamp);
|
|
83
|
+
if (rawTimestamp !== undefined && rawTimestamp > 0) {
|
|
84
|
+
return Math.floor(rawTimestamp);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawTimestampIso = String(entry?.timestamp || "").trim();
|
|
88
|
+
if (rawTimestampIso) {
|
|
89
|
+
const parsedDate = new Date(rawTimestampIso);
|
|
90
|
+
const parsedMs = parsedDate.valueOf();
|
|
91
|
+
if (Number.isFinite(parsedMs) && parsedMs > 0) return parsedMs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const messageTimestamp = toFiniteNumber(entry?.message?.timestamp);
|
|
95
|
+
if (messageTimestamp !== undefined && messageTimestamp > 0) {
|
|
96
|
+
return Math.floor(messageTimestamp);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const toDayKey = (timestampMs) =>
|
|
102
|
+
new Date(Number(timestampMs || 0)).toISOString().slice(0, 10);
|
|
103
|
+
|
|
104
|
+
const resolveSessionsDir = (openclawDir) =>
|
|
105
|
+
path.join(String(openclawDir || ""), "agents", "main", "sessions");
|
|
106
|
+
|
|
107
|
+
const listBackfillCandidateFiles = ({
|
|
108
|
+
openclawDir,
|
|
109
|
+
earliestExistingTimestampMs = null,
|
|
110
|
+
fsModule = fs,
|
|
111
|
+
}) => {
|
|
112
|
+
const sessionsDir = resolveSessionsDir(openclawDir);
|
|
113
|
+
if (!sessionsDir || !fsModule.existsSync(sessionsDir)) return [];
|
|
114
|
+
const entries = fsModule.readdirSync(sessionsDir, { withFileTypes: true });
|
|
115
|
+
const candidateFiles = [];
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (!entry?.isFile?.()) continue;
|
|
118
|
+
const fileName = String(entry.name || "");
|
|
119
|
+
if (!fileName.endsWith(kSessionFileSuffix)) continue;
|
|
120
|
+
if (!earliestExistingTimestampMs || earliestExistingTimestampMs <= 0) {
|
|
121
|
+
candidateFiles.push(fileName);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const absolutePath = path.join(sessionsDir, fileName);
|
|
125
|
+
try {
|
|
126
|
+
const stats = fsModule.statSync(absolutePath);
|
|
127
|
+
if (Number(stats?.mtimeMs || 0) < earliestExistingTimestampMs) {
|
|
128
|
+
candidateFiles.push(fileName);
|
|
129
|
+
}
|
|
130
|
+
} catch {}
|
|
131
|
+
}
|
|
132
|
+
return candidateFiles.sort((leftValue, rightValue) =>
|
|
133
|
+
leftValue.localeCompare(rightValue),
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const getEarliestUsageTimestampMs = (database) => {
|
|
138
|
+
const row = database
|
|
139
|
+
.prepare("SELECT MIN(timestamp) AS earliest_timestamp FROM usage_events")
|
|
140
|
+
.get();
|
|
141
|
+
const parsed = toNonNegativeInt(row?.earliest_timestamp);
|
|
142
|
+
return parsed && parsed > 0 ? parsed : null;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const readSessionsStore = ({ sessionsDir, fsModule = fs }) => {
|
|
146
|
+
const sessionsStorePath = path.join(sessionsDir, kSessionsStoreFileName);
|
|
147
|
+
if (!fsModule.existsSync(sessionsStorePath)) return {};
|
|
148
|
+
try {
|
|
149
|
+
const raw = fsModule.readFileSync(sessionsStorePath, "utf8");
|
|
150
|
+
const parsed = JSON.parse(raw);
|
|
151
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
152
|
+
} catch {
|
|
153
|
+
return {};
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const buildSessionContextByFileName = ({ sessionsDir, fsModule = fs }) => {
|
|
158
|
+
const sessionStore = readSessionsStore({ sessionsDir, fsModule });
|
|
159
|
+
const byFileName = new Map();
|
|
160
|
+
for (const [sessionKey, entry] of Object.entries(sessionStore)) {
|
|
161
|
+
const safeSessionKey = String(sessionKey || "").trim();
|
|
162
|
+
if (!safeSessionKey) continue;
|
|
163
|
+
const rawSessionId = String(entry?.sessionId || "").trim();
|
|
164
|
+
const rawSessionFile = String(entry?.sessionFile || "").trim();
|
|
165
|
+
const baseContext = {
|
|
166
|
+
sessionKey: safeSessionKey,
|
|
167
|
+
sessionId: rawSessionId,
|
|
168
|
+
};
|
|
169
|
+
if (rawSessionFile) {
|
|
170
|
+
byFileName.set(path.basename(rawSessionFile), baseContext);
|
|
171
|
+
}
|
|
172
|
+
if (rawSessionId) {
|
|
173
|
+
byFileName.set(`${rawSessionId}${kSessionFileSuffix}`, baseContext);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return byFileName;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const resolveSessionContext = ({ fileName, sessionContextByFileName }) => {
|
|
180
|
+
const mappedContext = sessionContextByFileName.get(fileName) || null;
|
|
181
|
+
if (mappedContext) return mappedContext;
|
|
182
|
+
return {
|
|
183
|
+
sessionKey: "",
|
|
184
|
+
sessionId: String(fileName || "").replace(/\.jsonl$/i, ""),
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const scanUsageRecordsFromFile = async ({
|
|
189
|
+
filePath,
|
|
190
|
+
onRecord = () => {},
|
|
191
|
+
onSkip = () => {},
|
|
192
|
+
earliestExistingTimestampMs = null,
|
|
193
|
+
}) => {
|
|
194
|
+
const stream = fs.createReadStream(filePath, { encoding: "utf8" });
|
|
195
|
+
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
196
|
+
try {
|
|
197
|
+
for await (const rawLine of lines) {
|
|
198
|
+
const trimmedLine = String(rawLine || "").trim();
|
|
199
|
+
if (!trimmedLine) {
|
|
200
|
+
onSkip();
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
let parsed = null;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(trimmedLine);
|
|
206
|
+
} catch {
|
|
207
|
+
onSkip();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const message = parsed?.message;
|
|
211
|
+
if (!message || typeof message !== "object") {
|
|
212
|
+
onSkip();
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (String(message.role || "").trim() !== "assistant") {
|
|
216
|
+
onSkip();
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const usage = normalizeUsage(message.usage ?? parsed?.usage);
|
|
220
|
+
if (!usage) {
|
|
221
|
+
onSkip();
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const timestampMs = parseTimestampMs(parsed);
|
|
225
|
+
if (!timestampMs) {
|
|
226
|
+
onSkip();
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (
|
|
230
|
+
earliestExistingTimestampMs &&
|
|
231
|
+
timestampMs >= earliestExistingTimestampMs
|
|
232
|
+
) {
|
|
233
|
+
onSkip();
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const provider =
|
|
237
|
+
String(message.provider || parsed?.provider || "").trim() || "unknown";
|
|
238
|
+
const model = String(message.model || parsed?.model || "").trim() || "unknown";
|
|
239
|
+
const runId =
|
|
240
|
+
String(
|
|
241
|
+
parsed?.runId ||
|
|
242
|
+
parsed?.run_id ||
|
|
243
|
+
message?.runId ||
|
|
244
|
+
message?.run_id ||
|
|
245
|
+
"",
|
|
246
|
+
).trim() || "";
|
|
247
|
+
onRecord({
|
|
248
|
+
timestampMs,
|
|
249
|
+
provider,
|
|
250
|
+
model,
|
|
251
|
+
runId,
|
|
252
|
+
...usage,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
lines.close();
|
|
257
|
+
stream.destroy();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const getBackfillStatus = ({ database, openclawDir, fsModule = fs }) => {
|
|
262
|
+
if (!database) throw new Error("Usage DB is not initialized");
|
|
263
|
+
const earliestExistingTimestampMs = getEarliestUsageTimestampMs(database);
|
|
264
|
+
const files = listBackfillCandidateFiles({
|
|
265
|
+
openclawDir,
|
|
266
|
+
earliestExistingTimestampMs,
|
|
267
|
+
fsModule,
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
available: files.length > 0,
|
|
271
|
+
estimatedFiles: files.length,
|
|
272
|
+
earliestExistingTimestampMs,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const backfillFromTranscripts = async ({
|
|
277
|
+
database,
|
|
278
|
+
openclawDir,
|
|
279
|
+
fsModule = fs,
|
|
280
|
+
}) => {
|
|
281
|
+
if (!database) throw new Error("Usage DB is not initialized");
|
|
282
|
+
const earliestExistingTimestampMs = getEarliestUsageTimestampMs(database);
|
|
283
|
+
const sessionsDir = resolveSessionsDir(openclawDir);
|
|
284
|
+
const files = listBackfillCandidateFiles({
|
|
285
|
+
openclawDir,
|
|
286
|
+
earliestExistingTimestampMs,
|
|
287
|
+
fsModule,
|
|
288
|
+
});
|
|
289
|
+
const sessionContextByFileName = buildSessionContextByFileName({
|
|
290
|
+
sessionsDir,
|
|
291
|
+
fsModule,
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const insertUsageEventStmt = database.prepare(`
|
|
295
|
+
INSERT INTO usage_events (
|
|
296
|
+
timestamp,
|
|
297
|
+
session_id,
|
|
298
|
+
session_key,
|
|
299
|
+
run_id,
|
|
300
|
+
provider,
|
|
301
|
+
model,
|
|
302
|
+
input_tokens,
|
|
303
|
+
output_tokens,
|
|
304
|
+
cache_read_tokens,
|
|
305
|
+
cache_write_tokens,
|
|
306
|
+
total_tokens
|
|
307
|
+
) VALUES (
|
|
308
|
+
$timestamp,
|
|
309
|
+
$session_id,
|
|
310
|
+
$session_key,
|
|
311
|
+
$run_id,
|
|
312
|
+
$provider,
|
|
313
|
+
$model,
|
|
314
|
+
$input_tokens,
|
|
315
|
+
$output_tokens,
|
|
316
|
+
$cache_read_tokens,
|
|
317
|
+
$cache_write_tokens,
|
|
318
|
+
$total_tokens
|
|
319
|
+
)
|
|
320
|
+
`);
|
|
321
|
+
const upsertUsageDailyStmt = database.prepare(`
|
|
322
|
+
INSERT INTO usage_daily (
|
|
323
|
+
date,
|
|
324
|
+
model,
|
|
325
|
+
provider,
|
|
326
|
+
input_tokens,
|
|
327
|
+
output_tokens,
|
|
328
|
+
cache_read_tokens,
|
|
329
|
+
cache_write_tokens,
|
|
330
|
+
total_tokens,
|
|
331
|
+
turn_count
|
|
332
|
+
) VALUES (
|
|
333
|
+
$date,
|
|
334
|
+
$model,
|
|
335
|
+
$provider,
|
|
336
|
+
$input_tokens,
|
|
337
|
+
$output_tokens,
|
|
338
|
+
$cache_read_tokens,
|
|
339
|
+
$cache_write_tokens,
|
|
340
|
+
$total_tokens,
|
|
341
|
+
1
|
|
342
|
+
)
|
|
343
|
+
ON CONFLICT(date, model) DO UPDATE SET
|
|
344
|
+
provider = COALESCE(excluded.provider, usage_daily.provider),
|
|
345
|
+
input_tokens = usage_daily.input_tokens + excluded.input_tokens,
|
|
346
|
+
output_tokens = usage_daily.output_tokens + excluded.output_tokens,
|
|
347
|
+
cache_read_tokens = usage_daily.cache_read_tokens + excluded.cache_read_tokens,
|
|
348
|
+
cache_write_tokens = usage_daily.cache_write_tokens + excluded.cache_write_tokens,
|
|
349
|
+
total_tokens = usage_daily.total_tokens + excluded.total_tokens,
|
|
350
|
+
turn_count = usage_daily.turn_count + 1
|
|
351
|
+
`);
|
|
352
|
+
|
|
353
|
+
let backfilledEvents = 0;
|
|
354
|
+
let skippedEvents = 0;
|
|
355
|
+
let filesScanned = 0;
|
|
356
|
+
database.exec("BEGIN");
|
|
357
|
+
try {
|
|
358
|
+
for (const fileName of files) {
|
|
359
|
+
filesScanned += 1;
|
|
360
|
+
const filePath = path.join(sessionsDir, fileName);
|
|
361
|
+
const sessionContext = resolveSessionContext({
|
|
362
|
+
fileName,
|
|
363
|
+
sessionContextByFileName,
|
|
364
|
+
});
|
|
365
|
+
await scanUsageRecordsFromFile({
|
|
366
|
+
filePath,
|
|
367
|
+
earliestExistingTimestampMs,
|
|
368
|
+
onSkip: () => {
|
|
369
|
+
skippedEvents += 1;
|
|
370
|
+
},
|
|
371
|
+
onRecord: (record) => {
|
|
372
|
+
insertUsageEventStmt.run({
|
|
373
|
+
$timestamp: record.timestampMs,
|
|
374
|
+
$session_id: String(sessionContext.sessionId || ""),
|
|
375
|
+
$session_key: String(sessionContext.sessionKey || ""),
|
|
376
|
+
$run_id: String(record.runId || ""),
|
|
377
|
+
$provider: record.provider,
|
|
378
|
+
$model: record.model,
|
|
379
|
+
$input_tokens: record.inputTokens,
|
|
380
|
+
$output_tokens: record.outputTokens,
|
|
381
|
+
$cache_read_tokens: record.cacheReadTokens,
|
|
382
|
+
$cache_write_tokens: record.cacheWriteTokens,
|
|
383
|
+
$total_tokens: record.totalTokens,
|
|
384
|
+
});
|
|
385
|
+
upsertUsageDailyStmt.run({
|
|
386
|
+
$date: toDayKey(record.timestampMs),
|
|
387
|
+
$model: record.model,
|
|
388
|
+
$provider: record.provider,
|
|
389
|
+
$input_tokens: record.inputTokens,
|
|
390
|
+
$output_tokens: record.outputTokens,
|
|
391
|
+
$cache_read_tokens: record.cacheReadTokens,
|
|
392
|
+
$cache_write_tokens: record.cacheWriteTokens,
|
|
393
|
+
$total_tokens: record.totalTokens,
|
|
394
|
+
});
|
|
395
|
+
backfilledEvents += 1;
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
database.exec("COMMIT");
|
|
400
|
+
} catch (error) {
|
|
401
|
+
database.exec("ROLLBACK");
|
|
402
|
+
throw error;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
backfilledEvents,
|
|
407
|
+
skippedEvents,
|
|
408
|
+
filesScanned,
|
|
409
|
+
cutoffMs: earliestExistingTimestampMs,
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
module.exports = {
|
|
414
|
+
getBackfillStatus,
|
|
415
|
+
backfillFromTranscripts,
|
|
416
|
+
};
|
|
@@ -6,6 +6,7 @@ const { getDailySummary } = require("./summary");
|
|
|
6
6
|
const { getSessionsList, getSessionDetail } = require("./sessions");
|
|
7
7
|
const { getSessionTimeSeries } = require("./timeseries");
|
|
8
8
|
const { kGlobalModelPricing } = require("./pricing");
|
|
9
|
+
const { getBackfillStatus, backfillFromTranscripts } = require("./backfill");
|
|
9
10
|
|
|
10
11
|
let db = null;
|
|
11
12
|
let usageDbPath = "";
|
|
@@ -31,5 +32,8 @@ module.exports = {
|
|
|
31
32
|
getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }),
|
|
32
33
|
getSessionTimeSeries: (options = {}) =>
|
|
33
34
|
getSessionTimeSeries({ database: ensureDb(), ...options }),
|
|
35
|
+
getBackfillStatus: (options = {}) => getBackfillStatus({ database: ensureDb(), ...options }),
|
|
36
|
+
backfillFromTranscripts: (options = {}) =>
|
|
37
|
+
backfillFromTranscripts({ database: ensureDb(), ...options }),
|
|
34
38
|
kGlobalModelPricing,
|
|
35
39
|
};
|
|
@@ -1,10 +1,33 @@
|
|
|
1
1
|
const topicRegistry = require("../topic-registry");
|
|
2
2
|
const { parsePositiveInt } = require("../utils/number");
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
3
5
|
|
|
4
6
|
const kSummaryCacheTtlMs = 60 * 1000;
|
|
5
7
|
const kClientTimeZoneHeader = "x-client-timezone";
|
|
6
8
|
|
|
7
9
|
const createSummaryCache = () => new Map();
|
|
10
|
+
const readOnboardingMarker = ({ onboardingMarkerPath, fsModule = fs }) => {
|
|
11
|
+
const safePath = String(onboardingMarkerPath || "").trim();
|
|
12
|
+
if (!safePath || !fsModule.existsSync(safePath)) return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(fsModule.readFileSync(safePath, "utf8"));
|
|
15
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const writeOnboardingMarker = ({
|
|
22
|
+
onboardingMarkerPath,
|
|
23
|
+
marker,
|
|
24
|
+
fsModule = fs,
|
|
25
|
+
}) => {
|
|
26
|
+
const safePath = String(onboardingMarkerPath || "").trim();
|
|
27
|
+
if (!safePath) return;
|
|
28
|
+
fsModule.mkdirSync(path.dirname(safePath), { recursive: true });
|
|
29
|
+
fsModule.writeFileSync(safePath, JSON.stringify(marker || {}, null, 2));
|
|
30
|
+
};
|
|
8
31
|
const toTitleLabel = (value) => {
|
|
9
32
|
const raw = String(value || "").trim();
|
|
10
33
|
if (!raw) return "";
|
|
@@ -92,6 +115,11 @@ const registerUsageRoutes = ({
|
|
|
92
115
|
getSessionsList,
|
|
93
116
|
getSessionDetail,
|
|
94
117
|
getSessionTimeSeries,
|
|
118
|
+
getBackfillStatus,
|
|
119
|
+
backfillFromTranscripts,
|
|
120
|
+
openclawDir = "",
|
|
121
|
+
onboardingMarkerPath = "",
|
|
122
|
+
fsModule = fs,
|
|
95
123
|
}) => {
|
|
96
124
|
const summaryCache = createSummaryCache();
|
|
97
125
|
|
|
@@ -151,6 +179,58 @@ const registerUsageRoutes = ({
|
|
|
151
179
|
res.status(500).json({ ok: false, error: err.message });
|
|
152
180
|
}
|
|
153
181
|
});
|
|
182
|
+
|
|
183
|
+
app.get("/api/usage/backfill/status", requireAuth, (req, res) => {
|
|
184
|
+
try {
|
|
185
|
+
const marker = readOnboardingMarker({ onboardingMarkerPath, fsModule });
|
|
186
|
+
if (String(marker?.usageBackfilledAt || "").trim()) {
|
|
187
|
+
res.json({ ok: true, available: false, estimatedFiles: 0 });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const status = getBackfillStatus({ openclawDir, fsModule });
|
|
191
|
+
res.json({
|
|
192
|
+
ok: true,
|
|
193
|
+
available: !!status?.available,
|
|
194
|
+
estimatedFiles: Number(status?.estimatedFiles || 0),
|
|
195
|
+
});
|
|
196
|
+
} catch (err) {
|
|
197
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
app.post("/api/usage/backfill", requireAuth, async (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const marker = readOnboardingMarker({ onboardingMarkerPath, fsModule });
|
|
204
|
+
if (String(marker?.usageBackfilledAt || "").trim()) {
|
|
205
|
+
res.json({
|
|
206
|
+
ok: true,
|
|
207
|
+
alreadyBackfilled: true,
|
|
208
|
+
backfilledEvents: 0,
|
|
209
|
+
skippedEvents: 0,
|
|
210
|
+
filesScanned: 0,
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const result = await backfillFromTranscripts({ openclawDir, fsModule });
|
|
215
|
+
writeOnboardingMarker({
|
|
216
|
+
onboardingMarkerPath,
|
|
217
|
+
fsModule,
|
|
218
|
+
marker: {
|
|
219
|
+
...marker,
|
|
220
|
+
onboarded:
|
|
221
|
+
typeof marker?.onboarded === "boolean" ? marker.onboarded : true,
|
|
222
|
+
reason: String(marker?.reason || "onboarding_complete"),
|
|
223
|
+
markedAt: String(marker?.markedAt || new Date().toISOString()),
|
|
224
|
+
usageBackfilledAt: new Date().toISOString(),
|
|
225
|
+
usageBackfilledEvents: Number(result?.backfilledEvents || 0),
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
summaryCache.clear();
|
|
229
|
+
res.json({ ok: true, ...result });
|
|
230
|
+
} catch (err) {
|
|
231
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
154
234
|
};
|
|
155
235
|
|
|
156
236
|
module.exports = { registerUsageRoutes };
|
package/lib/server.js
CHANGED
|
@@ -42,6 +42,8 @@ const {
|
|
|
42
42
|
getSessionsList,
|
|
43
43
|
getSessionDetail,
|
|
44
44
|
getSessionTimeSeries,
|
|
45
|
+
getBackfillStatus,
|
|
46
|
+
backfillFromTranscripts,
|
|
45
47
|
} = require("./server/db/usage");
|
|
46
48
|
const topicRegistry = require("./server/topic-registry");
|
|
47
49
|
const {
|
|
@@ -373,6 +375,11 @@ registerUsageRoutes({
|
|
|
373
375
|
getSessionsList,
|
|
374
376
|
getSessionDetail,
|
|
375
377
|
getSessionTimeSeries,
|
|
378
|
+
getBackfillStatus,
|
|
379
|
+
backfillFromTranscripts,
|
|
380
|
+
openclawDir: constants.OPENCLAW_DIR,
|
|
381
|
+
onboardingMarkerPath: constants.kOnboardingMarkerPath,
|
|
382
|
+
fsModule: fs,
|
|
376
383
|
});
|
|
377
384
|
registerDoctorRoutes({
|
|
378
385
|
app,
|