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
2 changes: 1 addition & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2070,7 +2070,7 @@ const usageStatisticsSchema = z.object({
consent: z.enum(["default", "enabled", "disabled"]),
sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(),
policy: z.string(), notice_required: z.boolean(),
next_payload: z.unknown(), aggregate_preview: z.unknown(),
next_payload: z.unknown(), aggregate_preview: z.unknown(), goal_preview: z.unknown(),
});
export type UsageStatistics = z.infer<typeof usageStatisticsSchema>;
export async function usageStatistics(enabled?: boolean): Promise<UsageStatistics> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +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>
{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 All @@ -34,7 +35,7 @@ export function UsageStatisticsSettings() {
: (zh ? `当前不发送:${({disabled:"已关闭",CI:"CI 环境",DO_NOT_TRACK:"请勿追踪开关",LOOPX_USAGE_PING:"环境变量已关闭",consent_required:"需要明确同意",invalid_policy:"策略配置无效",invalid_endpoint:"接收地址无效",notice_required:"需要重新告知"} as Record<string,string>)[state.blocked_by ?? ""] ?? "请检查配置"}` : `Not sending: ${state.blocked_by}`)}</p>
{state.notice_required && state.consent !== "disabled" ? <button className="personal-secondary-action" type="button" disabled={busy} onClick={() => void update(true)}>{zh ? "已了解,启用统计" : "Understood, enable statistics"}</button> : null}
<p>{zh ? "接收地址:" : "Recipient: "}{state.endpoint ?? (zh ? "未配置" : "Not configured")}</p>
<details><summary>{zh ? "查看待发送数据" : "Preview outgoing data"}</summary><pre>{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview }, null, 2)}</pre></details>
<details><summary>{zh ? "查看待发送数据" : "Preview outgoing data"}</summary><pre>{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview, goals: state.goal_preview }, null, 2)}</pre></details>
</> : null}
{error ? <p role="alert">{zh ? "无法读取或保存;请用终端检查:" : "Could not read or save; inspect in terminal: "}<code>loopx usage-ping status</code></p> : null}
<p><code>loopx usage-ping disable</code> · <code>LOOPX_USAGE_PING=0</code></p>
Expand Down
10 changes: 8 additions & 2 deletions apps/usage-collector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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 |
| `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 |
| `POST /v0/ping` | Retained six-field opt-in client contract; no new default-on clients use this route |
Expand Down Expand Up @@ -47,7 +49,11 @@ npx wrangler d1 migrations apply loopx-usage --remote
npx wrangler deploy
```

Qualify `/v1/ping`, `/v1/aggregate`, both stats endpoints, and invalid-field/size
Existing v1 installations also apply `0002-goal-usage.sql` before deploying
the Goal-duration Worker. Back up first; this only adds `goal_usage_counts`.
A Worker rollback can leave that additive table intact.

Qualify `/v1/ping`, `/v1/aggregate`, `/v1/goals`, all stats endpoints, and invalid-field/size
rejections on a separate database first. Deploy the collector before releasing
the new client default: the v0-only Worker does not accept v1 requests. Server
rollback can restore the prior Worker without dropping the additive columns
Expand All @@ -69,4 +75,4 @@ node --no-warnings --experimental-strip-types --test tests/control_plane_ts/usag
```

Run from repository root. The collector suite executes actual SQL in SQLite,
including the v0 migration; no production telemetry is needed for these tests.
including both additive migrations; no production telemetry is needed for these tests.
4 changes: 4 additions & 0 deletions apps/usage-collector/migrations/0002-goal-usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
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)
);
5 changes: 5 additions & 0 deletions apps/usage-collector/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ CREATE TABLE IF NOT EXISTS usage_counts (
count INTEGER NOT NULL,
PRIMARY KEY (day, feature, outcome, duration, error)
);

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)
);
18 changes: 18 additions & 0 deletions apps/usage-collector/src/basic-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,21 @@ export async function aggregateStats(db: Database, since: string) {
}
return { schema: "loopx_usage_aggregate_stats_v1", definition: "Lossy CLI invocation counts received in the last 30 UTC days; not people, installations or accepted Goal outcomes. Cells below 5 omitted.", totals };
}

export { validGoalAggregate } 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)));
}
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);
}
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 };
}
16 changes: 13 additions & 3 deletions apps/usage-collector/src/collector.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { validAggregate, validPing, recordAggregate, aggregateStats } from "./basic-usage.ts";
import { validAggregate, validPing, recordAggregate, aggregateStats, validGoalAggregate, recordGoals, goalStats } from "./basic-usage.ts";
// Pure request handling for the LoopX usage collector. worker.js binds it to
// Cloudflare; tests bind it to an in-memory database.

Expand Down 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_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 All @@ -143,16 +144,20 @@ export async function purge(db, day) {
export async function handle(request, db, now = new Date()) {
const url = new URL(request.url);
const day = utcDay(now);
if (url.pathname === "/v1/goal-stats") {
if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
return json(await goalStats(db, shiftDays(day, -29)), 200, { "cache-control": "public, max-age=3600" });
}
if (url.pathname === "/v1/aggregate-stats") {
if (request.method !== "GET") return json({ error: "method not allowed" }, 405);
return json(await aggregateStats(db, shiftDays(day, -29)), 200, { "cache-control": "public, max-age=3600" });
}
if (["/v0/ping", "/v1/ping", "/v1/aggregate"].includes(url.pathname)) {
if (["/v0/ping", "/v1/ping", "/v1/aggregate", "/v1/goals"].includes(url.pathname)) {
if (request.method !== "POST") return json({ error: "method not allowed" }, 405, { allow: "POST" });
if (!(request.headers.get("content-type") ?? "").startsWith("application/json")) {
return json({ error: "content-type must be application/json" }, 415);
}
const limit = url.pathname === "/v1/aggregate" ? 16384 : MAX_BODY_BYTES;
const limit = ["/v1/aggregate", "/v1/goals"].includes(url.pathname) ? 16384 : MAX_BODY_BYTES;
// Bound streaming reads too: Content-Length can be absent or untrusted.
const reader = request.body?.getReader();
if (!reader) return json({ error: "missing body" }, 400);
Expand All @@ -175,6 +180,11 @@ export async function handle(request, db, now = new Date()) {
} catch {
return json({ error: "invalid JSON" }, 400);
}
if (url.pathname === "/v1/goals") {
if (!validGoalAggregate(parsed)) return json({ error: "invalid goal aggregate" }, 400);
await recordGoals(db, parsed, day);
return new Response(null, { status: 204 });
}
if (url.pathname === "/v1/aggregate") {
if (!validAggregate(parsed)) return json({ error: "invalid aggregate" }, 400);
await recordAggregate(db, parsed, day);
Expand Down
29 changes: 29 additions & 0 deletions apps/usage-collector/test/collector.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,32 @@ test("existing v0 database upgrades without deleting heartbeat history", () => {
assert.equal(db.prepare("SELECT count(*) n FROM pings").get().n, 1);
db.close();
});

test("Goal duration ingestion uses real SQL, rejects identity, suppresses small cells and expires counts", async () => {
const db = d1();
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 }] };
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: {} });
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"]);
await purge(db, "2026-10-02");
assert.equal(db.raw.get("SELECT COUNT(*) n FROM goal_usage_counts").n, 0);
});

test("Goal migration is additive and preserves existing aggregate counters", () => {
const db = new DatabaseSync(":memory:");
db.exec("CREATE TABLE usage_counts (count INTEGER); INSERT INTO usage_counts VALUES (7)");
const migration = readFileSync(new URL("../migrations/0002-goal-usage.sql", import.meta.url), "utf8");
db.exec(migration); db.exec(migration);
assert.equal(db.prepare("SELECT count FROM usage_counts").get().count, 7);
assert.equal(db.prepare("SELECT count(*) n FROM goal_usage_counts").get().n, 0);
db.close();
});
62 changes: 58 additions & 4 deletions docs/reference/usage-ping.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ outcomes. No content collection is implemented.

```bash
loopx usage-ping status # current policy, recipient and outgoing payload previews
loopx usage-ping disable # stop both channels; delete local ID and pending counts
loopx usage-ping enable # explicitly allow both channels after reading the disclosure
loopx usage-ping disable # stop all channels; delete local ID and pending counts
loopx usage-ping enable # explicitly allow all channels after reading the disclosure
```

Settings → Capability Center exposes the same machine-wide switch and previews.
Expand Down Expand Up @@ -80,8 +80,8 @@ silently opt itself in: use the visible App setting or explicit CLI enable.
JSON stdout is unaffected. Previously enabled v0 clients keep their random ID
but must see the expanded-scope disclosure; previously disabled clients stay off.

An explicit stored disable blocks both channels. The following environment
settings also block both channels, even after explicit enable:
An explicit stored disable blocks all channels. The following environment
settings also block all channels, even after explicit enable:

- `LOOPX_USAGE_PING=0|false|no|off`
- `DO_NOT_TRACK` set to a nonempty value other than `0`
Expand Down Expand Up @@ -142,3 +142,57 @@ requests can never be correlated. Public unauthenticated counters can be
inflated, and suppression/loss makes these estimates unsuitable for billing.
The service may be unreachable on some networks; the owner can supply a reachable
collector, and LoopX continues to work without telemetry.

## Observed Goal duration

The Goal channel adds two duration histograms to basic statistics. It helps
answer whether observed work continues for hours or days, and how much Host
execution those spans contain. It does not identify a person or a Goal.

- **Span:** first to most recent observed Host execution, including intervening
pauses. It stops growing while no execution is observed.
- **Execution:** union of observed Host-call intervals for one Goal on one
machine. Concurrent or nested calls overlap only once; retry execution counts,
settlement-only replay does not. Network/tool/approval waits inside a Host call
are included; this is neither CPU time nor billing time.
- **Sampling:** one cumulative snapshot per locally observed Goal-day, flushed
after that UTC day closes when another observation or normal usage occurs.
Unfinished Goals are included. A continuously executing Host checkpoints every
minute and can flush without another CLI command. Quiet Goals are not counted
again every day. These counts are **Goal-day observations, not unique Goals**;
the collector cannot join a Goal across days or machines.

Coverage is managed `turn run-once` Host execution and regular owner Goal chat.
Native `/goal`, externally attached agent sessions, manager and external-audience
conversations are excluded because they do not share these timing boundaries.
File, SQLite and PostgreSQL use the same observer; no provider state is queried
or changed. Measurement starts when first observed after notice acknowledgment,
not at historical Goal creation. Disabling, changing recipient, or clearing local
state restarts measurement. There is no historical backfill or completion claim.

All durations are lower bounds on observed work: confirmed prefixes survive a
crash; missing final checkpoints, lock contention, network failure, suspended
hosts and collection limits can lose observations. Never extrapolate a crashed
Host as still executing. Local storage holds at most 64 Goals and 512 disjoint
recent intervals per Goal; old intervals compact into totals, and 90-day inactive
Goals expire. Delayed observations older than one day are discarded; pending
snapshots older than seven days are discarded. Do not use this channel for
liveness detection, quotas, acceptance, or accounting.

The only outgoing Goal payload is:

```json
{"schema":"loopx_goal_usage_aggregate_v1","counters":[{"span":"lt_7d","execution":"lt_6h","count":1}]}
```

Both durations use `lt_1m`, `lt_10m`, `lt_1h`, `lt_6h`, `lt_1d`, `lt_7d`,
`lt_30d`, `gte_30d`. No Goal ID, installation ID, source path, name, event time
or free text is sent. `/v1/goals` accepts the strict payload; `/v1/goal-stats`
returns independent marginal histograms for the last 30 receipt days and omits
cells below five. The existing usage settings switch, environment opt-outs and
consent policy control all three channels. Settings and `loopx usage-ping status`
show `goal_preview`; this is a current local snapshot, not a delivery receipt.
The expanded scope requires notice version 2; previous explicit disable persists.

Deploy collector migration `0002-goal-usage.sql` and its Worker before shipping
the client. This additive table leaves existing heartbeats and CLI counts intact.
Loading
Loading