Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function UsageStatisticsSettings() {
? "用于决定平台支持和改进命令体验。每天向 LoopX 的 Cloudflare 收集服务发送随机安装标识、版本、系统、CPU 架构、Python 版本和安装渠道;固定的 CLI 功能、结果、耗时区间和错误类别在本机按天汇总后另行发送,不带安装标识。"
: "Helps prioritize platform support and CLI improvements. A daily heartbeat sends a random installation ID, version, OS, CPU architecture, Python version and install channel to the LoopX Cloudflare collector. Fixed CLI feature, result, duration and error counts are aggregated locally by day and sent separately without the ID."}</p>
<p>{zh ? "不采集提示词、代码、路径、命令参数、Goal 内容或原始错误。当前功能计数仅覆盖 CLI;命令成功不等于 Goal 完成。" : "No prompts, code, paths, arguments, Goal contents or raw errors. Feature counts currently cover CLI only; command success is not Goal completion."}</p>
<p>{zh ? "另按天汇总受管 Turn 与普通 Goal 对话的执行跨度和 Host 调用累计时长区间(并行重叠只计一次),包括未结束的 Goal,不带 Goal 或安装标识。从本机开始观测后计量,可能漏计;不代表 Goal 完成、CPU 用时或计费时长。原生 /goal 和外部附加任务暂不覆盖。" : "Also aggregates observed Goal execution-span and Host-call duration buckets by day, including unfinished Goals; overlaps count once. No Goal or installation ID. Measurement begins locally and may undercount; it is not completion evidence, CPU time or billing. Covers managed Turns and regular owner Goal chat; native /goal and externally attached tasks are excluded."}</p>
<p>{zh ? "按天分别汇总所有 Host 的 quota→spend 推进周期、已绑定 Codex 任务的本地轮次时间、受管 Turn 与普通 Goal 对话的 Host 调用时间。上传固定 Host 类别及跨度/时长区间,不上传会话内容、Goal 或安装标识。三种口径重叠,不能相加;可能漏计,不代表完成、CPU 用时或计费。" : "Daily, separate span/duration buckets for all Hosts using quota→spend, local timing events from bound Codex tasks, and direct Host calls in managed Turns and regular owner Goal chat. Sends fixed Host categories, never session contents, Goal or installation IDs. The three overlapping populations cannot be added; partial observations are not completion, CPU time or billing."}</p>
{state ? <>
<label><input type="checkbox" checked={state.consent !== "disabled"} disabled={busy}
onChange={event => void update(event.target.checked)} /> {zh ? "允许基础使用统计(整台机器)" : "Allow basic usage statistics (this machine)"}</label>
Expand Down
11 changes: 8 additions & 3 deletions apps/usage-collector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The TypeScript client/collector allowlist lives in
|---|---|
| `POST /v1/ping` | Daily random-ID heartbeat with version/OS/CPU/Python/channel; ≤1 KiB |
| `POST /v1/aggregate` | Fixed CLI counts, no installation ID or join key; ≤16 KiB |
| `POST /v1/goals` | Observed Goal-day span/execution buckets, no identity; ≤16 KiB |
| `POST /v1/goals` | Independent Goal/measurement/Host-day span/duration buckets, no identity; ≤16 KiB |
| `GET /v1/goal-stats` | Independent 30-day duration histograms; cells below 5 omitted |
| `GET /v0/stats` | Deduplicated active/new installations, including retained v0 clients; version/OS/CPU/channel breakdown |
| `GET /v1/aggregate-stats` | Independent 30-day feature/result/duration/error totals; cells below 5 omitted |
Expand Down Expand Up @@ -49,8 +49,13 @@ npx wrangler d1 migrations apply loopx-usage --remote
npx wrangler deploy
```

Existing v1 installations also apply `0002-goal-usage.sql` before deploying
the Goal-duration Worker. Back up first; this only adds `goal_usage_counts`.
Existing v1 installations also apply `0002-goal-usage.sql` and then
`0003-goal-duration-sources.sql` before deploying the Goal-duration Worker.
Back up first. The latter adds `goal_duration_counts` and copies prior counts
as `host_call`/`unknown`, preserving the old table for rollback.
`quota_cycle`, `codex_turn` and `host_call` are overlapping populations and
must never be summed. The unreleased Goal payload requires measurement/Host
labels and uses `duration` instead of `execution`.
A Worker rollback can leave that additive table intact.

Qualify `/v1/ping`, `/v1/aggregate`, `/v1/goals`, all stats endpoints, and invalid-field/size
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS goal_duration_counts (
day TEXT NOT NULL, measurement TEXT NOT NULL, host TEXT NOT NULL,
span TEXT NOT NULL, duration TEXT NOT NULL, count INTEGER NOT NULL,
PRIMARY KEY (day, measurement, host, span, duration)
);
INSERT INTO goal_duration_counts (day, measurement, host, span, duration, count)
SELECT day, 'host_call', 'unknown', span, execution, count FROM goal_usage_counts
WHERE true ON CONFLICT (day, measurement, host, span, duration) DO NOTHING;
6 changes: 6 additions & 0 deletions apps/usage-collector/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ CREATE TABLE IF NOT EXISTS goal_usage_counts (
day TEXT NOT NULL, span TEXT NOT NULL, execution TEXT NOT NULL, count INTEGER NOT NULL,
PRIMARY KEY (day, span, execution)
);

CREATE TABLE IF NOT EXISTS goal_duration_counts (
day TEXT NOT NULL, measurement TEXT NOT NULL, host TEXT NOT NULL,
span TEXT NOT NULL, duration TEXT NOT NULL, count INTEGER NOT NULL,
PRIMARY KEY (day, measurement, host, span, duration)
);
24 changes: 15 additions & 9 deletions apps/usage-collector/src/basic-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,25 @@ export async function aggregateStats(db: Database, since: string) {
}

export { validGoalAggregate } from "../../../loopx/control_plane/runtime/usage_statistics_goal_contract.ts";
import { MEASUREMENTS } from "../../../loopx/control_plane/runtime/usage_statistics_goal_contract.ts";
import type { GoalAggregate } from "../../../loopx/control_plane/runtime/usage_statistics_goal_contract.ts";
export async function recordGoals(db: Database, value: GoalAggregate, day: string) {
await db.batch(value.counters.map(row => db.prepare(
"INSERT INTO goal_usage_counts (day, span, execution, count) VALUES (?1, ?2, ?3, ?4) " +
"ON CONFLICT (day, span, execution) DO UPDATE SET count = count + excluded.count",
).bind(day, row.span, row.execution, row.count)));
"INSERT INTO goal_duration_counts (day, measurement, host, span, duration, count) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
"ON CONFLICT (day, measurement, host, span, duration) DO UPDATE SET count = count + excluded.count",
).bind(day, row.measurement, row.host, row.span, row.duration, row.count)));
}
export async function goalStats(db: Database, since: string) {
const totals: Record<string, Record<string, number>> = {};
for (const column of ["span", "execution"]) {
const rows = await db.prepare(`SELECT ${column} AS key, SUM(count) AS n FROM goal_usage_counts WHERE day >= ?1 GROUP BY ${column}`).bind(since).all();
totals[column] = {};
for (const row of rows.results) if (Number(row.n) >= 5) totals[column][String(row.key)] = Number(row.n);
// Each measurement is an independent population. Never add the three clocks.
const measurements: Record<string, Record<string, Record<string, number>>> = {};
for (const measurement of MEASUREMENTS) {
const totals: Record<string, Record<string, number>> = {};
for (const column of ["span", "duration", "host"]) {
const rows = await db.prepare(`SELECT ${column} AS key, SUM(count) AS n FROM goal_duration_counts WHERE day >= ?1 AND measurement = ?2 GROUP BY ${column}`).bind(since, measurement).all();
totals[column] = {};
for (const row of rows.results) if (Number(row.n) >= 5) totals[column][String(row.key)] = Number(row.n);
}
measurements[measurement] = totals;
}
return { schema: "loopx_goal_usage_stats_v1", definition: "Lossy observed Goal-day snapshots received in the last 30 UTC days, including unfinished Goals. Span is first-to-last observed execution; execution is union of Host-call intervals, not CPU time. Lower bounds since measurement began; managed Turns and regular owner Goal chat only. Not unique Goals, completion evidence or billing. Cells below 5 omitted.", totals };
return { schema: "loopx_goal_usage_stats_v1", definition: "Lossy observed Goal-day samples in the last 30 receipt days, grouped by measurement. quota_cycle is admitted quota-to-successful-spend elapsed time for any Host, including pauses; codex_turn uses bound session timing; host_call uses direct invocation checkpoints. These populations overlap: do not add counts or durations. Span is first-to-last observed interval. Not unique Goals, completion, CPU time or billing. Cells below 5 omitted.", measurements };
}
1 change: 1 addition & 0 deletions apps/usage-collector/src/collector.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export async function purge(db, day) {
const cutoff = shiftDays(day, -RETENTION_DAYS);
await db.batch([
db.prepare("DELETE FROM pings WHERE day < ?1").bind(cutoff),
db.prepare("DELETE FROM goal_duration_counts WHERE day < ?1").bind(shiftDays(day, -30)),
db.prepare("DELETE FROM goal_usage_counts WHERE day < ?1").bind(shiftDays(day, -30)),
db.prepare("DELETE FROM usage_counts WHERE day < ?1").bind(shiftDays(day, -30)),
db.prepare("DELETE FROM installs WHERE install_id NOT IN (SELECT DISTINCT install_id FROM pings)"),
Expand Down
23 changes: 18 additions & 5 deletions apps/usage-collector/test/collector.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,18 +168,18 @@ test("Goal duration ingestion uses real SQL, rejects identity, suppresses small
const send = value => new Request("https://collector.example/v1/goals", {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(value),
});
const value = { schema: "loopx_goal_usage_aggregate_v1", counters: [{ span: "lt_30d", execution: "lt_1d", count: 3 }] };
const value = { schema: "loopx_goal_usage_aggregate_v1", counters: [{ measurement: "host_call", host: "unknown", span: "lt_30d", duration: "lt_1d", count: 3 }] };
for (const field of ["goal_id", "install_id", "timestamp", "prompt"]) {
assert.equal((await handle(send({ ...value, [field]: "private" }), db)).status, 400);
}
assert.equal((await handle(send(value), db, at("2026-09-01"))).status, 204);
const stats = () => handle(new Request("https://collector.example/v1/goal-stats"), db, at("2026-09-02")).then(r => r.json());
assert.deepEqual((await stats()).totals, { span: {}, execution: {} });
assert.deepEqual((await stats()).measurements.host_call, { span: {}, duration: {}, host: {} });
await handle(send(value), db, at("2026-09-01"));
assert.deepEqual((await stats()).totals, { span: { lt_30d: 6 }, execution: { lt_1d: 6 } });
assert.deepEqual(Object.keys(db.raw.get("SELECT * FROM goal_usage_counts")).sort(), ["count", "day", "execution", "span"]);
assert.deepEqual((await stats()).measurements.host_call, { span: { lt_30d: 6 }, duration: { lt_1d: 6 }, host: { unknown: 6 } });
assert.deepEqual(Object.keys(db.raw.get("SELECT * FROM goal_duration_counts")).sort(), ["count", "day", "duration", "host", "measurement", "span"]);
await purge(db, "2026-10-02");
assert.equal(db.raw.get("SELECT COUNT(*) n FROM goal_usage_counts").n, 0);
assert.equal(db.raw.get("SELECT COUNT(*) n FROM goal_duration_counts").n, 0);
});

test("Goal migration is additive and preserves existing aggregate counters", () => {
Expand All @@ -191,3 +191,16 @@ test("Goal migration is additive and preserves existing aggregate counters", ()
assert.equal(db.prepare("SELECT count(*) n FROM goal_usage_counts").get().n, 0);
db.close();
});

test("measurement migration preserves historical counts and is safe to repeat", () => {
const db = new DatabaseSync(":memory:");
db.exec(readFileSync(new URL("../migrations/0002-goal-usage.sql", import.meta.url), "utf8"));
db.exec("INSERT INTO goal_usage_counts VALUES ('2026-09-01','lt_7d','lt_6h',8)");
const migration = readFileSync(new URL("../migrations/0003-goal-duration-sources.sql", import.meta.url), "utf8");
db.exec(migration); db.exec(migration);
assert.deepEqual({...db.prepare("SELECT * FROM goal_duration_counts").get()}, {
day:"2026-09-01", measurement:"host_call", host:"unknown", span:"lt_7d", duration:"lt_6h", count:8,
});
assert.equal(db.prepare("SELECT count FROM goal_usage_counts").get().count,8);
db.close();
});
Loading
Loading