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
9 changes: 8 additions & 1 deletion apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2251,6 +2251,7 @@ export async function disconnectLarkGoalTopic(goalId: string, connectionId: stri
);
}

export { CONTEXTS as usageContexts } from "../../../../../loopx/control_plane/runtime/usage_statistics_contract";
const usageStatisticsSchema = z.object({
consent: z.enum(["default", "enabled", "disabled"]),
sending: z.boolean(), blocked_by: z.string().nullable(), endpoint: z.string().nullable(),
Expand All @@ -2259,12 +2260,18 @@ const usageStatisticsSchema = z.object({
automatic_notice_required: z.boolean(),
next_payload: z.unknown(), aggregate_preview: z.unknown(), goal_preview: z.unknown(),
diagnostic_preview: z.unknown().optional(), diagnostic_dropped: z.number().optional(),
stored_context: z.string().optional(), effective_context: z.string().optional(), context_source: z.string().optional(),
installation_preview: z.unknown().optional(),
identity_scope: z.string().optional(), delivery_history: z.array(z.object({
day: z.string(), channel: z.enum(["heartbeat", "cli", "goal"]), rows: z.number(),
day: z.string(), channel: z.enum(["heartbeat", "cli", "goal", "installation"]), rows: z.number(),
status: z.enum(["accepted", "rejected", "unavailable"]),
})).optional(),
});
export type UsageStatistics = z.infer<typeof usageStatisticsSchema>;
export async function setUsageContext(context: string): Promise<UsageStatistics> {
return usageStatisticsSchema.parse(await requestJson<unknown>("/api/chat/usage-statistics",
{ method: "POST", body: JSON.stringify({ context }) }));
}
export async function usageStatistics(enabled?: boolean): Promise<UsageStatistics> {
return usageStatisticsSchema.parse(await requestJson<unknown>("/api/chat/usage-statistics",
enabled === undefined ? undefined : { method: "POST", body: JSON.stringify({ enabled }) }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ export function UsageStatisticsNotice({ onDetails }: { onDetails: () => void })
: state.automatic_notice_required ? (zh ? "基础使用统计 · 告知后自动开启" : "Basic usage statistics · enabled after this notice")
: (zh ? "基础使用统计当前不发送,请查看详情" : "Basic usage statistics are not sending; see details")}</strong>
<p>{zh
? "用于改进平台支持与使用体验。发送随机安装标识和环境信息,另行汇总 CLI 子操作、版本/活动日期、结果/耗时与回执信号,以及 Goal 时长。环境类型自愿声明,默认未知;不采集对话、代码、路径或参数值。可随时关闭。"
: "Helps improve platform support and usage. Sends a random installation ID and environment information, plus separate CLI sub-operation, release/activity day, result/timing, receipt signals and Goal duration summaries. Deployment context is voluntary, unknown by default. No conversations, code, paths or argument values. You can turn it off at any time."}</p>
? "用于改进平台支持与使用体验。随机安装标识会关联每日 CLI 功能计数、版本、日期、自愿环境标签与已观测运行分钟。区间按安装去重;不同计时口径不能相加,不代表机器在线或任务完成。不采集对话、代码、路径或参数值。可随时关闭。"
: "Helps improve platform support and usage. A random installation ID links to daily CLI counts, version, date, voluntary context and observed runtime minutes. Overlapping intervals are deduplicated per installation; different clocks cannot be added, and are not uptime or task completion. No conversations, code, paths or argument values. You can turn it off at any time."}</p>
<p>{zh
? "首个已测量的 CLI 结果立即上报,后续由使用活动触发,至少间隔 15 分钟发送一批。CLI 汇总不含安装标识。"
: "The first measured CLI result is sent immediately; later activity sends buffered counts at most once every 15 minutes. CLI summaries contain no installation ID."}</p>
? "独立无 ID 的 CLI 汇总保留;新增安装级概要由活动触发,至少间隔 15 分钟发送。关闭会清除本机标识和测量记录,已发送的记录不能撤回。"
: "ID-free CLI summaries remain supported. New installation profiles are activity-triggered, at least 15 minutes apart. Disabling clears local ID and measurement history; it cannot recall already-sent records."}</p>
<p className="personal-usage-recipient">{zh ? "接收方:" : "Recipient: "}{state.endpoint}</p>
{error ? <p role="alert">{zh ? "设置未能保存,请打开详情重试。" : "Could not save this setting. Open details to retry."}</p> : null}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { usageStatistics, type UsageStatistics } from "../../data/chat";
import { setUsageContext, usageContexts, usageStatistics, type UsageStatistics } from "../../data/chat";
import { useWorkspaceI18n } from "./i18n";

export function UsageStatisticsSettings() {
Expand All @@ -20,22 +20,37 @@ export function UsageStatisticsSettings() {
catch { setError(true); }
finally { setBusy(false); }
}
async function updateContext(context: string) {
setBusy(true); setError(false);
try { setState(await setUsageContext(context)); }
catch { setError(true); }
finally { setBusy(false); }
}
return <details className="personal-capability-scope-note personal-usage-statistics" data-testid="usage-statistics-settings">
<summary>{zh ? "基础使用统计 · 告知后默认开启,可关闭" : "Basic usage statistics · on after notice, optional"}</summary>
<p>{zh
? "用于决定平台支持和改进命令体验。每天向 LoopX 的 Cloudflare 收集服务发送随机安装标识、版本、系统、CPU 架构、Python 版本和安装渠道;固定的 CLI 功能、结果、耗时区间和错误类别在本机汇总,不带安装标识。首个可采集的命令结果立即尝试发送,之后有活动时每隔至少 15 分钟发送一批。"
: "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 without the ID. The first measured result attempts a send immediately; later activity sends batches at least 15 minutes apart."}</p>
<p>{zh ? "新增固定子操作、版本、UTC 活动日期、阻塞/失败分类和已回读的生命周期信号。运行环境类型仅由 LOOPX_USAGE_CONTEXT 自愿声明,默认 unknown,不推断个人或企业。不会上传提示词、代码、路径、参数值、Goal 内容或原始错误。命令成功不等于 Goal 完成。" : "Adds fixed sub-operations, release version, UTC activity date, blocked/failure classes and receipt-backed lifecycle signals. Deployment context is voluntary via LOOPX_USAGE_CONTEXT, unknown by default, never inferred. No prompts, code, paths, argument values, Goal contents or raw errors. Command success is not Goal completion."}</p>
? "用于决定平台支持和改进命令体验。每天向 LoopX 的 Cloudflare 收集服务发送随机安装标识、版本、系统、CPU 架构、Python 版本和安装渠道。原有独立 CLI 汇总不带安装标识,包含固定功能、结果、耗时区间和错误类别;首个命令结果立即尝试发送,之后有活动时每隔至少 15 分钟发送一批。"
: "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. The existing separate CLI summaries remain ID-free, with fixed feature, result, duration and error counts. The first measured result attempts a send immediately; later activity sends batches at least 15 minutes apart."}</p>
<p>{zh ? "安装级每日概要将同一个随机安装标识与固定 CLI 功能计数、版本、UTC 日期、自愿环境标签和已观测运行分钟关联。设备设置可持久保存,LOOPX_USAGE_CONTEXT 优先;默认未知,不推断个人或企业。不上传对话、代码、路径、参数值或原始错误。" : "Daily profiles link the same random installation ID with fixed CLI counts, version, UTC date, voluntary context and observed runtime minutes. Device labels persist; LOOPX_USAGE_CONTEXT overrides them. Unknown by default; never inferred. No conversations, code, paths, argument values or raw errors."}</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>{zh ? "这台设备的用途(自愿声明)" : "Device deployment context (voluntary)"}
<select value={state.stored_context ?? "unknown"} disabled={busy} onChange={event => void updateContext(event.target.value)}>
{usageContexts.map(value =>
<option key={value} value={value}>{zh ? ({ unknown: "未知", personal: "个人使用", shared_service: "共享服务",
ephemeral: "临时环境", organization_managed: "组织管理", maintainer: "项目维护者" })[value] : value}</option>)}
</select>
</label>
<p>{zh ? "实际生效:" : "Effective: "}{state.effective_context ?? "unknown"} · {state.context_source ?? "default"}.
{zh ? " 环境变量优先;设置用途不会开启统计或改变安装标识,已记录的每日标签不改写。" : " Environment overrides the device label. This setting does not enable statistics or change the ID; recorded daily labels are not rewritten."}</p>
<label><input type="checkbox" checked={state.consent === "enabled" || (state.consent === "default" && !state.notice_required)} disabled={busy}
onChange={event => void update(event.target.checked)} /> {zh ? "允许基础使用统计(整台机器)" : "Allow basic usage statistics (this machine)"}</label>
<p role="status">{state.sending ? (zh ? "已允许发送" : "Sending allowed")
: state.notice_required && state.consent !== "disabled" ? (zh ? "等待首次告知确认;尚未发送" : "Awaiting first-use acknowledgment; not sending")
: (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 and local delivery summaries"}</summary><pre>{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview, diagnostics: state.diagnostic_preview, goals: state.goal_preview, identity_scope: state.identity_scope, dropped: state.diagnostic_dropped, delivery_history: state.delivery_history }, null, 2)}</pre></details>
<details><summary>{zh ? "查看待发送数据与本地发送摘要" : "Preview outgoing data and local delivery summaries"}</summary><pre>{JSON.stringify({ heartbeat: state.next_payload, aggregate: state.aggregate_preview, diagnostics: state.diagnostic_preview, goals: state.goal_preview, installation: state.installation_preview, identity_scope: state.identity_scope, dropped: state.diagnostic_dropped, delivery_history: state.delivery_history }, 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
13 changes: 13 additions & 0 deletions apps/usage-collector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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` | Independent Goal/measurement/Host-day span/duration buckets, no identity; ≤16 KiB |
| `POST /v1/installation` | Installation-linked fixed daily CLI counts and independent interval-union minutes; ≤16 KiB; operator-only rows |
| `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 @@ -80,6 +81,18 @@ pings and legacy counters. An older Worker rejects new diagnostics; loss is
not retried. Roll back the Worker/client without dropping the additive table.
Merging this code does not deploy the collector.

Before releasing notice-v6 clients, back up and apply additive migration
`0005-installation-usage.sql`, then deploy the Worker. `installation_usage`
keeps one snapshot per random installation/UTC activity day for 30 activity
days. Newer revisions replace counts, never add them; context and version
freeze per day. No historical profiles or runtime are backfilled. Rollback
retains the table, and all existing endpoint contracts remain supported.
Daily runtime is partial instrumented interval union, **not uptime**. Different
clocks cannot be added. [Operator read queries](queries/installation-usage.sql)
separate span, active days, fixed-family usage and measured minutes.
Only authorized D1/Access-protected operator surfaces may render linked rows;
do not add per-ID results to unauthenticated public stats.

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
Expand Down
9 changes: 9 additions & 0 deletions apps/usage-collector/migrations/0005-installation-usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Additive. No historical linkage, context or runtime backfill.
CREATE TABLE IF NOT EXISTS installation_usage (
activity_day TEXT NOT NULL, install_id TEXT NOT NULL,
version TEXT NOT NULL, context TEXT NOT NULL, revision INTEGER NOT NULL,
cli TEXT NOT NULL, runtime TEXT NOT NULL, truncated INTEGER NOT NULL,
receipt_day TEXT NOT NULL,
PRIMARY KEY (activity_day, install_id)
);
CREATE INDEX IF NOT EXISTS installation_usage_id ON installation_usage (install_id);
35 changes: 35 additions & 0 deletions apps/usage-collector/queries/installation-usage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
-- Owner-only D1 read queries. Bind :from_day and :through_day (UTC dates),
-- including the published CI-exclusion cutover and latest partial day.
-- Missing historical profiles are UNKNOWN, never zero runtime or no usage.
-- Do not publish installation IDs or cross-dimensional small cells.

-- Reporting state continuity, not people, work time or continuous uptime.
SELECT install_id, MIN(day) AS first_observed_day, MAX(day) AS last_observed_day,
COUNT(*) AS observed_days,
CAST(julianday(MAX(day)) - julianday(MIN(day)) AS INTEGER) + 1 AS calendar_span_days
FROM pings WHERE day BETWEEN :from_day AND :through_day
GROUP BY install_id;

-- Fixed CLI distribution. Exclusion covers explicitly declared days only.
SELECT json_extract(j.value, '$.feature') AS feature,
COUNT(DISTINCT i.install_id) AS reporting_installations,
SUM(json_extract(j.value, '$.count')) AS observed_calls
FROM installation_usage i, json_each(i.cli) j
WHERE i.activity_day BETWEEN :from_day AND :through_day AND i.context != 'maintainer'
GROUP BY feature;

-- Windowed installation runtime: deduplicated daily union, separate clocks.
-- Presence below one minute may report zero; absence must remain unknown.
SELECT i.install_id, json_extract(j.value, '$.measurement') AS measurement,
COUNT(DISTINCT i.activity_day) AS measured_days,
SUM(json_extract(j.value, '$.observed_minutes')) AS observed_minutes,
MAX(i.truncated) AS contains_truncated_day
FROM installation_usage i, json_each(i.runtime) j
WHERE i.activity_day BETWEEN :from_day AND :through_day AND i.context != 'maintainer'
GROUP BY i.install_id, measurement;

-- Observe coverage before interpreting runtime/CLI as adoption.
SELECT context, COUNT(DISTINCT install_id) AS installations,
COUNT(*) AS profile_days, SUM(truncated) AS truncated_days
FROM installation_usage WHERE activity_day BETWEEN :from_day AND :through_day
GROUP BY context;
9 changes: 9 additions & 0 deletions apps/usage-collector/schema.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
-- LoopX usage collector (Cloudflare D1). One row per installation per UTC day.
CREATE TABLE IF NOT EXISTS installation_usage (
activity_day TEXT NOT NULL, install_id TEXT NOT NULL,
version TEXT NOT NULL, context TEXT NOT NULL, revision INTEGER NOT NULL,
cli TEXT NOT NULL, runtime TEXT NOT NULL, truncated INTEGER NOT NULL,
receipt_day TEXT NOT NULL,
PRIMARY KEY (activity_day, install_id)
);
CREATE INDEX IF NOT EXISTS installation_usage_id ON installation_usage (install_id);

CREATE TABLE IF NOT EXISTS installs (
install_id TEXT PRIMARY KEY,
first_day TEXT NOT NULL
Expand Down
Loading
Loading