From 39d985d45b7d6bdf70fb2e1a914d18a9d196f6e5 Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:26:35 +0000 Subject: [PATCH 1/6] feat(telemetry): accept identifier-free update outcomes Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- README.md | 14 ++- docs/update-results.md | 131 ++++++++++++++++++++++++++ src/env.ts | 2 + src/feature-stats.ts | 14 +-- src/index.ts | 35 ++++++- src/page.ts | 3 + src/update-result.ts | 64 +++++++++++++ test/deployment.test.ts | 7 ++ test/fixtures/update-result.json | 20 ++++ test/latest-version-runtime.test.mjs | 42 ++++++++- test/latest-version.test.ts | 2 +- test/update-result.test.ts | 133 +++++++++++++++++++++++++++ wrangler.jsonc | 4 + 13 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 docs/update-results.md create mode 100644 src/update-result.ts create mode 100644 test/fixtures/update-result.json create mode 100644 test/update-result.test.ts diff --git a/README.md b/README.md index b2817c0..e5bf8bd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ written from a validated request. | `GET \| POST /api/latest-version` | Returns `{ version, note? }`. `version` is the latest published OpenClaw release (looked up from the npm registry and cached at the edge for 5 minutes). `note` is an optional short message shown in the operator's terminal, used only when a release is worth acting on immediately. | | `GET /` | Human-readable page: what is collected, how to turn it off, without a public statistics dashboard. | +## Identifier-free update outcomes + +The same POST endpoint also accepts a strict schema-2 `update_result` event from +a companion client implementation. These outcome reports use a **separate dataset** +and never include geography or daily feature/identity rows. All fields are required +public labels; unknown keys, invalid labels, malformed UTF-8 and bodies over 4096 +bytes are rejected. This receiver change does not enable a client or deploy collection. +See [the wire contract, storage columns, retention and private aggregate SQL](docs/update-results.md). +The daily-check behavior described below is unchanged. + ## What an install sends With automatic update checks enabled, OpenClaw reuses a successful version check for 24 hours. @@ -72,7 +82,7 @@ each field; missing or invalid values are left empty without discarding valid fi ## What is stored -Each recorded request contributes one Analytics Engine data point with these columns and no others: +Each recorded daily update check contributes one Analytics Engine data point with these columns and no others (schema-2 outcome storage is documented separately above): | Column | Value | | --- | --- | @@ -99,7 +109,7 @@ enablement. The session count depends on creation events still retained in a bou Missing or unreadable state produces zero; this is not active sessions, messages, or all sessions that existed that day. -Unknown keys in a request body are dropped rather than stored, so a future client cannot silently +Unknown keys in a schema-1 request body are dropped rather than stored, so a future client cannot silently widen what this service keeps. User-Agents longer than 512 characters become an unknown identity before parsing. Identity fields remain length-bounded and character-filtered. Feature IDs must be complete identifiers of at most 64 characters; malformed or overlength IDs are dropped, never diff --git a/docs/update-results.md b/docs/update-results.md new file mode 100644 index 0000000..08a4214 --- /dev/null +++ b/docs/update-results.md @@ -0,0 +1,131 @@ +# Identifier-free update outcomes (schema 2) + +This receiver accepts a terminal update outcome on the existing +`POST /api/latest-version` endpoint. It is separate from daily update checks and +schema-1 feature reports. This change does not enable a client, deploy the Worker, +provision a production dataset, or authorize collection. Client consent and +transport policy remain the responsibility of the companion client change. + +## Wire contract + +Send JSON with fixed User-Agent `openclaw-update-result/1` (not stored). All 18 +fields in `test/fixtures/update-result.json` are mandatory; additional keys are +rejected, not ignored. The only numeric field is `schema: 2`; `event` is exactly +`update_result`. Other values must exactly match these case-sensitive labels: + +| Field | Accepted values | +| --- | --- | +| outcome | succeeded, failed, rolled-back | +| fromVersion, targetVersion, resultingVersion, runningVersion | public release syntax below, or unknown | +| platform | linux, darwin, win32, freebsd, openbsd, unknown | +| arch | x64, arm64, arm, ia32, unknown | +| installMethod | git-checkout, npm-global, pnpm-global, bun-global, managed-service, unknown | +| channel | stable, beta, dev, extended-stable, unknown | +| duration | under-10s, under-1m, under-5m, under-30m, over-30m, unknown | +| postCheck | passed, failed, unknown | +| failedStage | requested, staging, validating, repairing, activating, restarting, verifying, unknown, none | +| errorCategory | permission, network, timeout, storage, other, none | +| errorCode | EACCES, EPERM, ENOSPC, ETIMEDOUT, ECONNRESET, ECONNREFUSED, ENOTFOUND, unknown, none | +| rollback | not-needed, not-attempted, succeeded, failed, unknown | +| recovery | safe, unsafe, unknown | + +Public version syntax is +`/^202[0-9]\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[01])(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/`, +matching the entire string (including rejecting trailing line terminators). +It excludes build metadata, commit SHAs and private prerelease labels. This is +syntax validation, not a claim that a label was actually published. `succeeded` +requires `failedStage`, `errorCategory` and `errorCode` all to be `none`. + +Uploads must be valid UTF-8 and at most **4096 bytes**, including whitespace and +any BOM. The fixed UA selects the 4096-byte streaming cap before JSON decoding, +so malformed, absent or oversized outcome bodies never reach the legacy recorder. +A parsed `schema: 2` or `event: "update_result"` also selects strict validation +without that UA, using the exact byte count from the legacy bounded reader. +Without the fixed UA, undecodable bodies cannot be classified as outcomes and +retain legacy invalid-feature behavior; clients must always send the fixed UA. + +Invalid outcomes return `400 {"error":"invalid_update_result"}` with no recording. +A missing outcome binding or synchronous analytics write failure returns +`503 {"error":"update_results_unavailable"}`; there is **no fallback** to the daily +dataset. Neither response echoes input or diagnostics. Accepted requests receive +the existing version response (or its existing `503 version_unavailable`). A +version failure can occur after recording; clients must not infer exactly-once +storage or retry to recover an acknowledgement. The existing per-IP recording +limiter is unchanged: exhausted callers still receive their version answer, +without recording. IP is only a transient limiter key, never an analytics column. + +## Storage and privacy + +Binding `UPDATE_RESULTS` writes exclusively to `openclaw_update_results`. The daily +`TELEMETRY` binding and `openclaw_telemetry` columns remain unchanged. Outcome +processing does not read `request.cf`, parse legacy identity, load the feature +vocabulary, or write any daily row. No IDs, raw User-Agent, geography, hostname, +path, command, arbitrary error text, logs or free-form strings are stored. +Worker observability and invocation logs stay disabled. Cloudflare still +processes connection metadata independently of these Worker storage rules. + +The positional contract in `src/update-result.ts` is: + +| Column | Value | +| --- | --- | +| index1 | targetVersion (sampling key, not an identifier) | +| blob1 | event | +| blob2 | outcome | +| blob3 | fromVersion | +| blob4 | targetVersion | +| blob5 | resultingVersion | +| blob6 | runningVersion | +| blob7 | platform | +| blob8 | arch | +| blob9 | installMethod | +| blob10 | channel | +| blob11 | duration | +| blob12 | postCheck | +| blob13 | failedStage | +| blob14 | errorCategory | +| blob15 | errorCode | +| blob16 | rollback | +| blob17 | recovery | +| double1 | schema (2) | + +Analytics Engine adds its own receipt timestamp and sampling weight. Its published +retention is **three months**; this change adds no archive, backup or export job. +Seventeen blobs, one double and one bounded version index fit the published +limits. Operators must confirm retention and the separate binding before a +separately authorized rollout. No production binding was provisioned or verified +by local tests. References: Cloudflare Analytics Engine +[limits](https://developers.cloudflare.com/analytics/analytics-engine/limits/) and +[SQL API](https://developers.cloudflare.com/analytics/analytics-engine/sql-api/), +checked September 19, 2026. + +## Aggregate-only analysis + +No public statistics or individual report route is added. Authorized operators +can use the private Analytics Engine SQL API for bounded aggregates, for example: + +```sql +SELECT blob4 AS target_version, blob2 AS outcome, + SUM(_sample_interval) AS reports +FROM openclaw_update_results +WHERE timestamp > NOW() - INTERVAL '7' DAY AND double1 = 2 +GROUP BY blob4, blob2 +ORDER BY reports DESC +``` + +```sql +SELECT blob13 AS failed_stage, blob14 AS error_category, + SUM(_sample_interval) AS reports +FROM openclaw_update_results +WHERE timestamp > NOW() - INTERVAL '7' DAY + AND double1 = 2 AND blob2 != 'succeeded' +GROUP BY blob13, blob14 +ORDER BY reports DESC +``` + +These are report counts, not unique installs, people or attempts. There are no +identifiers for deduplication or longitudinal joins. Missing reports, opt-in +selection, NAT rate limits, unauthenticated spoofing and sampling bias the counts. +Do not treat them as fleet-wide success rates, billing or security evidence. +Avoid individual-row exports or joins to daily geography; review any aggregate +publication separately for small groups. These SQL examples were not run against +production data. diff --git a/src/env.ts b/src/env.ts index d17361d..c96b5f9 100644 --- a/src/env.ts +++ b/src/env.ts @@ -4,6 +4,8 @@ export type RateLimiter = { export type Env = { TELEMETRY: AnalyticsEngineDataset; + /** Separate, identifier-free outcome dataset; never fall back to TELEMETRY. */ + UPDATE_RESULTS?: AnalyticsEngineDataset; /** Per-IP limit on recorded update checks. */ RATE_LIMIT?: RateLimiter; }; diff --git a/src/feature-stats.ts b/src/feature-stats.ts index 20a901b..518d165 100644 --- a/src/feature-stats.ts +++ b/src/feature-stats.ts @@ -3,17 +3,17 @@ import { parseFeatureStats } from "./payload.js"; /** Body cap: the documented payload is well under 1 KB. */ export const MAX_BODY_BYTES = 16_384; -function rejectDeclaredLength(request: Request): boolean { +function rejectDeclaredLength(request: Request, maxBytes: number): boolean { const header = request.headers.get("content-length"); if (header === null) return false; if (!/^[0-9]+$/.test(header)) return true; const declared = Number(header); - return !Number.isSafeInteger(declared) || declared > MAX_BODY_BYTES; + return !Number.isSafeInteger(declared) || declared > maxBytes; } /** Count stream bytes so a missing Content-Length cannot allocate the whole body. */ -async function readCappedText(request: Request): Promise { - if (rejectDeclaredLength(request)) return undefined; +export async function readCappedBody(request: Request, maxBytes = MAX_BODY_BYTES): Promise<{ text: string; byteLength: number } | undefined> { + if (rejectDeclaredLength(request, maxBytes)) return undefined; const body = request.body; if (!body) return undefined; @@ -27,7 +27,7 @@ async function readCappedText(request: Request): Promise { if (done) break; if (!value?.byteLength) continue; total += value.byteLength; - if (total > MAX_BODY_BYTES) { + if (total > maxBytes) { await reader.cancel().catch(() => undefined); return undefined; } @@ -45,7 +45,7 @@ async function readCappedText(request: Request): Promise { offset += chunk.byteLength; } try { - return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes); + return { text: new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes), byteLength: total }; } catch { return undefined; } @@ -53,7 +53,7 @@ async function readCappedText(request: Request): Promise { export async function readFeatureStats(request: Request) { if (request.method !== "POST") return undefined; - const raw = await readCappedText(request); + const raw = (await readCappedBody(request))?.text; if (!raw) return undefined; const parsed = ((): unknown => { try { diff --git a/src/index.ts b/src/index.ts index 5c348b2..6af72a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,11 @@ import { keepKnownNames, loadKnownNames, normalizeVersion } from "./allowlist.js"; import { buildDataPoint } from "./analytics.js"; import type { Env } from "./env.js"; -import { readFeatureStats } from "./feature-stats.js"; +import { readCappedBody } from "./feature-stats.js"; import { parseRequestGeography } from "./geography.js"; -import { parseClientIdentity } from "./payload.js"; +import { parseClientIdentity, parseFeatureStats } from "./payload.js"; +import type { FeatureStats } from "./payload.js"; +import { buildUpdateResultPoint, isUpdateResultCandidate, MAX_UPDATE_RESULT_BYTES, parseUpdateResult, UPDATE_RESULT_USER_AGENT } from "./update-result.js"; import { renderHomePage } from "./page.js"; const UPSTREAM_VERSION_URL = "https://registry.npmjs.org/openclaw/latest"; @@ -90,12 +92,11 @@ async function mayRecord(request: Request, env: Env): Promise { return outcome?.success !== false; } -async function recordRequest(request: Request, env: Env): Promise { +async function recordRequest(request: Request, env: Env, features: FeatureStats | undefined): Promise { // Over-limit callers still get their answer below; they just stop counting. if (!(await mayRecord(request, env))) return; const identity = parseClientIdentity(request.headers.get("user-agent")); - const features = await readFeatureStats(request); const known = features ? await loadKnownNames() : undefined; const validated = features ? { @@ -120,8 +121,32 @@ async function recordRequest(request: Request, env: Env): Promise { } async function handleLatestVersion(request: Request, env: Env): Promise { - await recordRequest(request, env); + let parsed: unknown; + if (request.method === "POST") { + // The fixed UA keeps even malformed/oversized outcome uploads off the + // legacy identity/geography path. JSON discriminators also work without it. + const outcomeAgent = request.headers.get("user-agent") === UPDATE_RESULT_USER_AGENT; + const body = await readCappedBody(request, outcomeAgent ? MAX_UPDATE_RESULT_BYTES : undefined); + const raw = body?.text; + try { parsed = raw ? JSON.parse(raw) : undefined; } catch { /* Never log request bodies. */ } + if (outcomeAgent || isUpdateResultCandidate(parsed)) { + const result = body && body.byteLength <= MAX_UPDATE_RESULT_BYTES + ? parseUpdateResult(parsed) : undefined; + if (!result) return jsonResponse({ error: "invalid_update_result" }, 400); + if (!env.UPDATE_RESULTS) return jsonResponse({ error: "update_results_unavailable" }, 503); + if (await mayRecord(request, env)) { + try { env.UPDATE_RESULTS.writeDataPoint(buildUpdateResultPoint(result)); } + catch { return jsonResponse({ error: "update_results_unavailable" }, 503); } + } + // Keep the existing limiter response: a version answer even when not recorded. + return latestVersionResponse(); + } + } + await recordRequest(request, env, parseFeatureStats(parsed)); + return latestVersionResponse(); +} +async function latestVersionResponse(): Promise { const latest = await fetchLatestVersion(); if (!latest) return jsonResponse({ error: "version_unavailable" }, 503); return jsonResponse(RELEASE_NOTE ? { ...latest, note: RELEASE_NOTE } : latest, 200, VERSION_CACHE_SECONDS); diff --git a/src/page.ts b/src/page.ts index a184566..01d3eba 100644 --- a/src/page.ts +++ b/src/page.ts @@ -54,6 +54,9 @@ footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--line

Interactive setup defaults to No thanks; guided Quick Start skips the question. Scripted installs do not opt in automatically. The enabled setting controls inclusion, not whether a prompt was answered.

Channels and providers describe configuration; plugins describe enabled inventory, not invocations. sessionsLast24h counts retained session-creation events timestamped in the preceding 24 hours, not active sessions or messages. Missing or unreadable local state produces zero.

+

Update outcomes

+

The receiver also supports identifier-free terminal update outcomes from a companion client implementation. These use a separate dataset, strict public version labels and bounded outcome categories, with no geography, install IDs, raw errors or logs. Uploads are limited to 4096 bytes. Reports are retained for three months; no public individual-report route is provided. Receiver support does not itself enable client reporting. See the outcome contract and collection boundaries.

+

Approximate location

Cloudflare provides approximate location: country, region code, city, and timezone. We store no raw IP addresses or precise coordinates in analytics.

Recorded update checks include these fields even when anonymous feature statistics are off or DO_NOT_TRACK is set. Missing or invalid fields stay empty. Records are retained for three months.

diff --git a/src/update-result.ts b/src/update-result.ts new file mode 100644 index 0000000..c5a544c --- /dev/null +++ b/src/update-result.ts @@ -0,0 +1,64 @@ +import type { DataPoint } from "./analytics.js"; + +export const MAX_UPDATE_RESULT_BYTES = 4_096; +export const UPDATE_RESULT_USER_AGENT = "openclaw-update-result/1"; +const PUBLIC_VERSION = /^202[0-9]\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[01])(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/; + +// Every value is a bounded public label, never an arbitrary diagnostic string. +const LABELS = { + outcome: ["succeeded", "failed", "rolled-back"], + platform: ["linux", "darwin", "win32", "freebsd", "openbsd", "unknown"], + arch: ["x64", "arm64", "arm", "ia32", "unknown"], + installMethod: ["git-checkout", "npm-global", "pnpm-global", "bun-global", "managed-service", "unknown"], + channel: ["stable", "beta", "dev", "extended-stable", "unknown"], + duration: ["under-10s", "under-1m", "under-5m", "under-30m", "over-30m", "unknown"], + postCheck: ["passed", "failed", "unknown"], + failedStage: ["requested", "staging", "validating", "repairing", "activating", "restarting", "verifying", "unknown", "none"], + errorCategory: ["permission", "network", "timeout", "storage", "other", "none"], + errorCode: ["EACCES", "EPERM", "ENOSPC", "ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "unknown", "none"], + rollback: ["not-needed", "not-attempted", "succeeded", "failed", "unknown"], + recovery: ["safe", "unsafe", "unknown"], +} as const; +const VERSIONS = ["fromVersion", "targetVersion", "resultingVersion", "runningVersion"] as const; +type VersionField = typeof VERSIONS[number]; +export type UpdateResult = { + schema: 2; + event: "update_result"; +} & { [K in keyof typeof LABELS]: typeof LABELS[K][number] } & Record; +const KEYS = new Set(["schema", "event", ...VERSIONS, ...Object.keys(LABELS)]); + +export function isUpdateResultCandidate(value: unknown): boolean { + return typeof value === "object" && value !== null && + (("schema" in value && value.schema === 2) || ("event" in value && value.event === "update_result")); +} + +export function parseUpdateResult(value: unknown): UpdateResult | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const record = value as Record; + if (Object.keys(record).length !== KEYS.size || Object.keys(record).some((key) => !KEYS.has(key))) return undefined; + if (record.schema !== 2 || record.event !== "update_result") return undefined; + for (const key of VERSIONS) { + const version = record[key]; + if (typeof version !== "string" || (version !== "unknown" && PUBLIC_VERSION.exec(version)?.[0] !== version)) return undefined; + } + for (const [key, labels] of Object.entries(LABELS)) { + if (typeof record[key] !== "string" || !(labels as readonly string[]).includes(record[key])) return undefined; + } + if (record.outcome === "succeeded" && + (record.failedStage !== "none" || record.errorCategory !== "none" || record.errorCode !== "none")) return undefined; + return record as UpdateResult; +} + +/** Dedicated dataset contract: no request metadata, IDs, geography, or raw errors. */ +export function buildUpdateResultPoint(result: UpdateResult): DataPoint { + return { + indexes: [result.targetVersion], + blobs: [ + result.event, result.outcome, result.fromVersion, result.targetVersion, + result.resultingVersion, result.runningVersion, result.platform, result.arch, + result.installMethod, result.channel, result.duration, result.postCheck, + result.failedStage, result.errorCategory, result.errorCode, result.rollback, result.recovery, + ], + doubles: [result.schema], + }; +} diff --git a/test/deployment.test.ts b/test/deployment.test.ts index 823002d..43aaa1c 100644 --- a/test/deployment.test.ts +++ b/test/deployment.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from "vitest"; import { experimental_readRawConfig } from "wrangler"; describe("deployment privacy", () => { + it("keeps outcome and daily datasets separate", () => { + const { rawConfig } = experimental_readRawConfig({ config: "wrangler.jsonc" }); + expect(rawConfig.analytics_engine_datasets).toEqual([ + { binding: "TELEMETRY", dataset: "openclaw_telemetry" }, + { binding: "UPDATE_RESULTS", dataset: "openclaw_update_results" }, + ]); + }); it("explicitly disables Worker observability and request logging", () => { const { rawConfig } = experimental_readRawConfig({ config: "wrangler.jsonc", diff --git a/test/fixtures/update-result.json b/test/fixtures/update-result.json new file mode 100644 index 0000000..9ad4c7d --- /dev/null +++ b/test/fixtures/update-result.json @@ -0,0 +1,20 @@ +{ + "schema": 2, + "event": "update_result", + "outcome": "succeeded", + "fromVersion": "2026.9.4", + "targetVersion": "2026.9.19", + "resultingVersion": "2026.9.19", + "runningVersion": "2026.9.19", + "platform": "linux", + "arch": "x64", + "installMethod": "npm-global", + "channel": "stable", + "duration": "under-1m", + "postCheck": "passed", + "failedStage": "none", + "errorCategory": "none", + "errorCode": "none", + "rollback": "not-needed", + "recovery": "unknown" +} diff --git a/test/latest-version-runtime.test.mjs b/test/latest-version-runtime.test.mjs index 4fa429a..a8c852e 100644 --- a/test/latest-version-runtime.test.mjs +++ b/test/latest-version-runtime.test.mjs @@ -1,4 +1,6 @@ import { createRequire } from "node:module"; +import { readFileSync } from "node:fs"; +const outcomeFixture = JSON.parse(readFileSync(new URL("./fixtures/update-result.json", import.meta.url), "utf8")); import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { experimental_readRawConfig } from "wrangler"; @@ -31,18 +33,23 @@ describe("update checks over workerd HTTP", () => { export default { async fetch(request, env) { let point; + let outcomePoint; // Inject Unicode here: Miniflare's cf override header corrupts it in transit. const incoming = new Request(request, { cf: { ...request.cf, city: " Sa\\u0303o Paulo " }, }); const response = await worker.fetch(incoming, { ...env, + UPDATE_RESULTS: { writeDataPoint(value) { + env.UPDATE_RESULTS.writeDataPoint(value); + outcomePoint = value; + } }, TELEMETRY: { writeDataPoint(value) { env.TELEMETRY.writeDataPoint(value); point = value; } }, }); - return Response.json({ status: response.status, body: await response.json(), point }); + return Response.json({ status: response.status, body: await response.json(), point, outcomePoint }); }, }; `, @@ -68,7 +75,7 @@ describe("update checks over workerd HTTP", () => { port: 0, cf: false, ratelimits: Object.fromEntries(rawConfig.ratelimits.map(({ name, ...limit }) => [name, limit])), - analyticsEngineDatasets: { TELEMETRY: { dataset: "test_telemetry" } }, + analyticsEngineDatasets: { TELEMETRY: { dataset: "test_telemetry" }, UPDATE_RESULTS: { dataset: "test_update_results" } }, outboundService: async (request) => { const url = new URL(request.url); if (url.href === "https://registry.npmjs.org/openclaw/latest") { @@ -106,6 +113,37 @@ describe("update checks over workerd HTTP", () => { } }, 30_000); + it("isolates schema-2 outcomes and rejects invalid uploads over local HTTP", async () => { + await start(recordingScript); + const origin = await runtime.ready; + const raw = JSON.stringify(outcomeFixture); + for (const [body, status] of [ + [raw, 200], + [JSON.stringify({ ...outcomeFixture, installId: "synthetic-private-id" }), 400], + [JSON.stringify({ ...outcomeFixture, targetVersion: "private-build-sha" }), 400], + [raw.padEnd(4097), 400], + [Buffer.from([0xff]), 400], + ]) { + const response = await fetch(new URL("/api/latest-version", origin), { + method: "POST", body, + headers: { "content-type": "application/json", "user-agent": "openclaw-update-result/1" }, + }); + const result = await response.json(); + expect(result.status).toBe(status); + expect(result.point).toBeUndefined(); + if (status === 200) { + expect(result.outcomePoint).toEqual({ + indexes: ["2026.9.19"], + blobs: ["update_result", "succeeded", "2026.9.4", "2026.9.19", "2026.9.19", "2026.9.19", "linux", "x64", "npm-global", "stable", "under-1m", "passed", "none", "none", "none", "not-needed", "unknown"], + doubles: [2], + }); + } else { + expect(result.outcomePoint).toBeUndefined(); + expect(result.body).toEqual({ error: "invalid_update_result" }); + } + } + }, 30_000); + it("retires public stats over HTTP while update checks remain available", async () => { await start(); const origin = await runtime.ready; diff --git a/test/latest-version.test.ts b/test/latest-version.test.ts index c6201e9..b0205bf 100644 --- a/test/latest-version.test.ts +++ b/test/latest-version.test.ts @@ -250,7 +250,7 @@ describe("GET | POST /api/latest-version", () => { }); }); - it.each(["{", JSON.stringify({ schema: 2, features: {} }), "x".repeat(16_385)])( + it.each(["{", JSON.stringify({ schema: 3, features: {} }), "x".repeat(16_385)])( "retains baseline geography when the feature body is invalid: %#", async (body) => { const response = await worker.fetch(updateRequest(geography, { method: "POST", body }), env); diff --git a/test/update-result.test.ts b/test/update-result.test.ts new file mode 100644 index 0000000..50b4dbf --- /dev/null +++ b/test/update-result.test.ts @@ -0,0 +1,133 @@ +import fixture from "./fixtures/update-result.json"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Env } from "../src/env.js"; +import worker from "../src/index.js"; +import { buildUpdateResultPoint, parseUpdateResult, UPDATE_RESULT_USER_AGENT } from "../src/update-result.js"; + +const raw = JSON.stringify(fixture); +const versions = ["fromVersion", "targetVersion", "resultingVersion", "runningVersion"]; + +describe("strict update-result contract", () => { + it("accepts the shared synthetic fixture and pins every stored column", () => { + const parsed = parseUpdateResult(fixture)!; + expect(parsed).toEqual(fixture); + expect(buildUpdateResultPoint(parsed)).toEqual({ + indexes: ["2026.9.19"], + blobs: ["update_result", "succeeded", "2026.9.4", "2026.9.19", "2026.9.19", "2026.9.19", "linux", "x64", "npm-global", "stable", "under-1m", "passed", "none", "none", "none", "not-needed", "unknown"], + doubles: [2], + }); + }); + it.each(Object.keys(fixture))("requires field %s with its exact type and vocabulary", (key) => { + const missing: Record = { ...fixture }; + delete missing[key]; + expect(parseUpdateResult(missing)).toBeUndefined(); + for (const value of [null, {}, [], true, 42, "private-value", "", "unknown\n"]) { + expect(parseUpdateResult({ ...fixture, [key]: value })).toBeUndefined(); + } + }); + it.each(["installId", "deviceId", "userId", "account", "hostname", "ip", "city", "country", "timezone", "trigger", "error", "stack", "logs", "__proto__"])("rejects additional %s", (key) => { + expect(parseUpdateResult({ ...fixture, [key]: "private" })).toBeUndefined(); + }); + it.each(versions)("restricts %s to public release syntax", (key) => { + for (const version of ["2026.9.19", "2026.9.19-1", "2026.9.19-beta.1", "2026.9.19-999-beta.999", "unknown"]) { + expect(parseUpdateResult({ ...fixture, [key]: version })).toBeDefined(); + } + for (const version of ["2026x9x19", "2026.9.19\n", "2026.9.19+abc123", "2026.9.19-private.1", "abcdef1234", "2026.09.19", "2026.13.1", "2026.1.32", "2026.9.19-0", "2026.9.19-1000", "2026.9.19-beta.0", "2026.9.19-beta.1000", "2030.1.1"]) { + expect(parseUpdateResult({ ...fixture, [key]: version })).toBeUndefined(); + } + }); + it.each(["failedStage", "errorCategory", "errorCode"])("requires succeeded %s to be none", (key) => { + const value = { failedStage: "verifying", errorCategory: "network", errorCode: "ENOTFOUND" }[key]; + expect(parseUpdateResult({ ...fixture, [key]: value })).toBeUndefined(); + }); + it.each(["failed", "rolled-back"])("accepts bounded %s diagnostics", (outcome) => { + expect(parseUpdateResult({ ...fixture, outcome, failedStage: "restarting", errorCategory: "permission", errorCode: "EACCES", rollback: "succeeded", recovery: "safe" })).toBeDefined(); + }); +}); + +describe("outcome receiver isolation", () => { + const daily = vi.fn(); + const outcomes = vi.fn(); + const upstream = vi.fn(); + let env: Env; + function request(body: BodyInit = raw, headers: Record = {}): Request { + const req = new Request("https://telemetry.example/api/latest-version", { + method: "POST", body, headers: { "user-agent": UPDATE_RESULT_USER_AGENT, "content-type": "application/json", "cf-connecting-ip": "192.0.2.1", "cookie": "private-cookie", ...headers }, + }); + Object.defineProperty(req, "cf", { get() { throw new Error("outcome must not read geography"); } }); + return req; + } + beforeEach(() => { + vi.resetAllMocks(); + env = { TELEMETRY: { writeDataPoint: daily }, UPDATE_RESULTS: { writeDataPoint: outcomes } }; + vi.stubGlobal("caches", { default: { match: async () => undefined, put: async () => {} } }); + upstream.mockResolvedValue(Response.json({ version: "2026.9.19" })); + vi.stubGlobal("fetch", upstream); + }); + afterEach(() => { vi.unstubAllGlobals(); }); + it.each([UPDATE_RESULT_USER_AGENT, "untrusted-agent"])("discriminates schema without storing UA %s or metadata", async (ua) => { + const response = await worker.fetch(request(raw, { "user-agent": ua }), env); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ version: "2026.9.19" }); + expect(outcomes).toHaveBeenCalledExactlyOnceWith(buildUpdateResultPoint(parseUpdateResult(fixture)!)); + expect(daily).not.toHaveBeenCalled(); + }); + it.each(["{", "null", "[]", "", JSON.stringify({ schema: 2, features: {} }), JSON.stringify({ ...fixture, installId: "secret" }), JSON.stringify({ ...fixture, runningVersion: "private-sha" })])("rejects invalid outcome without fallback: %#", async (body) => { + const response = await worker.fetch(request(body), env); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "invalid_update_result" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(outcomes).not.toHaveBeenCalled(); + expect(daily).not.toHaveBeenCalled(); + expect(upstream).not.toHaveBeenCalled(); + }); + it.each([UPDATE_RESULT_USER_AGENT, "other-agent"])("enforces exact body bytes including BOM for %s", async (ua) => { + for (const [body, status] of [[raw.padEnd(4096), 200], [raw.padEnd(4097), 400], ["\uFEFF" + raw.padEnd(4094), 400]] as const) { + upstream.mockResolvedValueOnce(Response.json({ version: "2026.9.19" })); + expect((await worker.fetch(request(body, { "user-agent": ua }), env)).status).toBe(status); + } + expect(outcomes).toHaveBeenCalledTimes(1); + expect(daily).not.toHaveBeenCalled(); + }); + it.each(["4097", "-1", "NaN", "1.5"])("rejects invalid declared length %s", async (length) => { + expect((await worker.fetch(request(raw, { "content-length": length }), env)).status).toBe(400); + expect(outcomes).not.toHaveBeenCalled(); + expect(daily).not.toHaveBeenCalled(); + }); + it("rejects malformed UTF-8", async () => { + expect((await worker.fetch(request(new Uint8Array([0xff])), env)).status).toBe(400); + expect(outcomes).not.toHaveBeenCalled(); + expect(daily).not.toHaveBeenCalled(); + }); + it("caps a chunked stream without Content-Length and cancels excess", async () => { + const cancel = vi.fn(); + const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(raw)); controller.enqueue(new Uint8Array(4096)); }, cancel }); + const req = request(); + Object.defineProperty(req, "body", { value: stream }); + expect((await worker.fetch(req, env)).status).toBe(400); + expect(cancel).toHaveBeenCalledOnce(); + expect(outcomes).not.toHaveBeenCalled(); + expect(daily).not.toHaveBeenCalled(); + }); + it.each(["missing", "throws"])("fails closed when outcome dataset %s", async (mode) => { + if (mode === "missing") delete env.UPDATE_RESULTS; + else outcomes.mockImplementationOnce(() => { throw new Error("private backend diagnostic"); }); + const response = await worker.fetch(request(), env); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ error: "update_results_unavailable" }); + expect(daily).not.toHaveBeenCalled(); + }); + it.each([false, true, "unavailable"])("preserves limiter behavior: %s", async (success) => { + const limit = success === "unavailable" ? vi.fn().mockRejectedValue(new Error("offline")) : vi.fn().mockResolvedValue({ success }); + const response = await worker.fetch(request(), { ...env, RATE_LIMIT: { limit } }); + expect(response.status).toBe(200); + expect(limit).toHaveBeenCalledExactlyOnceWith({ key: "192.0.2.1" }); + expect(outcomes).toHaveBeenCalledTimes(success === false ? 0 : 1); + expect(daily).not.toHaveBeenCalled(); + }); + it("does not add an individual report route", async () => { + for (const path of ["/api/update-results", "/api/update-result", "/api/stats"]) { + expect((await worker.fetch(new Request("https://telemetry.example" + path), env)).status).toBe(404); + } + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index d9de28e..60bbdc1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -23,6 +23,10 @@ { "binding": "TELEMETRY", "dataset": "openclaw_telemetry" + }, + { + "binding": "UPDATE_RESULTS", + "dataset": "openclaw_update_results" } ], "routes": [ From 960dd6597bdb9881bd859ed1c8822cc2c719013f Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:26:56 +0000 Subject: [PATCH 2/6] feat(telemetry): accept identifier-free update outcomes Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> OpenClaw-Publication: 6f22cf3b-82c5-4ca7-9d1c-c79f01c7dbd7 From 20e22a5f5c1c5c984700b589215369d448969fef Mon Sep 17 00:00:00 2001 From: roboclaw-bot <309084314+roboclaw-bot@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:46:21 +0000 Subject: [PATCH 3/6] fix(telemetry): align update outcomes with default-on client Accept bounded extended-stable patch versions in all four outcome fields. Clarify update-policy controls and require receiver readiness before releasing the default-on client. Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> --- README.md | 14 +++++++++++--- docs/update-results.md | 24 +++++++++++++++++++----- src/page.ts | 6 +++--- src/update-result.ts | 2 +- test/public-surface.test.ts | 4 ++++ test/update-result.test.ts | 14 +++++++++++++- 6 files changed, 51 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e5bf8bd..29490ba 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,15 @@ and never include geography or daily feature/identity rows. All fields are requi public labels; unknown keys, invalid labels, malformed UTF-8 and bodies over 4096 bytes are rejected. This receiver change does not enable a client or deploy collection. See [the wire contract, storage columns, retention and private aggregate SQL](docs/update-results.md). -The daily-check behavior described below is unchanged. +The companion client reports outcomes **on by default**, like the existing update +ping, under update-request policy rather than optional feature-statistics consent. +`update.checkOnStart: false`, `OPENCLAW_NO_AUTO_UPDATE=1`, and Nix mode suppress +outcomes; a truthy `CI` suppresses them unless a replacement +`OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured. `DO_NOT_TRACK` and +`openclaw telemetry off` control feature statistics, not update outcomes. Feature +statistics remain off by default. Deploy and verify this receiver and its separate +dataset **before releasing the default-on client**, under separately authorized +rollout. The daily-check behavior described below is unchanged. ## What an install sends @@ -179,9 +187,9 @@ processing is outside those settings. | Command or setting | Effect | | --- | --- | -| `openclaw telemetry off` | Stops anonymous feature statistics. Update checks continue. | +| `openclaw telemetry off` | Stops anonymous feature statistics. Update checks and default-on outcomes continue. | | `DO_NOT_TRACK=1` | Same, enforced from the environment. | -| `update.checkOnStart: false` | Stops both tiers of automatic update requests. Explicit update commands and other configured services are separate. | +| `update.checkOnStart: false` | Stops automatic update requests and outcome reporting. Explicit update commands and other configured services are separate. | `OPENCLAW_NO_AUTO_UPDATE=1` also prevents automatic update requests. A truthy `CI` suppresses both tiers unless a replacement `OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured. diff --git a/docs/update-results.md b/docs/update-results.md index 08a4214..7db78ef 100644 --- a/docs/update-results.md +++ b/docs/update-results.md @@ -3,8 +3,18 @@ This receiver accepts a terminal update outcome on the existing `POST /api/latest-version` endpoint. It is separate from daily update checks and schema-1 feature reports. This change does not enable a client, deploy the Worker, -provision a production dataset, or authorize collection. Client consent and -transport policy remain the responsibility of the companion client change. +provision a production dataset, or authorize production collection. The companion +client reports outcomes **on by default**, like the existing update ping, governed +by update-request policy rather than optional schema-1 feature statistics. +`update.checkOnStart: false`, `OPENCLAW_NO_AUTO_UPDATE=1`, and Nix mode suppress +outcome reports. A truthy `CI` suppresses them unless a replacement +`OPENCLAW_TELEMETRY_ENDPOINT` is explicitly configured. `DO_NOT_TRACK=1`, +`openclaw telemetry off`, and `telemetry.enabled` control feature statistics, not +default-on update outcomes; feature statistics remain off by default. + +The receiver and separate outcome dataset must be deployed and verified **before +releasing the default-on client**. That rollout needs separate authorization; +local tests do not establish production readiness. ## Wire contract @@ -30,8 +40,11 @@ rejected, not ignored. The only numeric field is `schema: 2`; `event` is exactly | recovery | safe, unsafe, unknown | Public version syntax is -`/^202[0-9]\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[01])(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/`, +`/^202[0-9]\.(?:[1-9]|1[0-2])\.(?:0|[1-9][0-9]{0,5})(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/`, matching the entire string (including rejecting trailing line terminators). +The patch component accepts zero or a non-zero-leading integer up to six digits +(0–999999), including extended-stable versions such as `2026.8.33` and +`2026.8.123`. Year, month, revision and beta-suffix restrictions are unchanged. It excludes build metadata, commit SHAs and private prerelease labels. This is syntax validation, not a claim that a label was actually published. `succeeded` requires `failedStage`, `errorCategory` and `errorCode` all to be `none`. @@ -123,8 +136,9 @@ ORDER BY reports DESC ``` These are report counts, not unique installs, people or attempts. There are no -identifiers for deduplication or longitudinal joins. Missing reports, opt-in -selection, NAT rate limits, unauthenticated spoofing and sampling bias the counts. +identifiers for deduplication or longitudinal joins. Missing reports, update-policy +opt-outs, Nix/CI suppression, NAT rate limits, unauthenticated spoofing and sampling +bias the counts. Do not treat them as fleet-wide success rates, billing or security evidence. Avoid individual-row exports or joins to daily geography; review any aggregate publication separately for small groups. These SQL examples were not run against diff --git a/src/page.ts b/src/page.ts index 01d3eba..8f992c9 100644 --- a/src/page.ts +++ b/src/page.ts @@ -55,7 +55,7 @@ footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--line

Channels and providers describe configuration; plugins describe enabled inventory, not invocations. sessionsLast24h counts retained session-creation events timestamped in the preceding 24 hours, not active sessions or messages. Missing or unreadable local state produces zero.

Update outcomes

-

The receiver also supports identifier-free terminal update outcomes from a companion client implementation. These use a separate dataset, strict public version labels and bounded outcome categories, with no geography, install IDs, raw errors or logs. Uploads are limited to 4096 bytes. Reports are retained for three months; no public individual-report route is provided. Receiver support does not itself enable client reporting. See the outcome contract and collection boundaries.

+

The receiver also supports identifier-free terminal update outcomes from a companion client implementation. These use a separate dataset, strict public version labels and bounded outcome categories, with no geography, install IDs, raw errors or logs. Uploads are limited to 4096 bytes. Reports are retained for three months; no public individual-report route is provided. The companion client reports outcomes on by default, like the existing update ping, under update-request policy rather than optional feature-statistics consent. update.checkOnStart: false, OPENCLAW_NO_AUTO_UPDATE=1, and Nix mode suppress outcomes; a truthy CI suppresses them unless a replacement OPENCLAW_TELEMETRY_ENDPOINT is configured. DO_NOT_TRACK controls feature statistics, not update outcomes. Receiver support does not itself enable client reporting: deploy and verify the receiver and separate dataset before releasing the default-on client, under separately authorized rollout. See the outcome contract and collection boundaries.

Approximate location

Cloudflare provides approximate location: country, region code, city, and timezone. We store no raw IP addresses or precise coordinates in analytics.

@@ -74,9 +74,9 @@ footer { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--line

How to turn it off

- + - +
Command or settingEffect
openclaw telemetry offStops anonymous feature statistics. Update checks continue.
openclaw telemetry offStops anonymous feature statistics. Update checks and default-on outcomes continue.
DO_NOT_TRACK=1Same, enforced from the environment.
update.checkOnStart: falseStops both tiers of automatic update requests. Explicit updates and other configured services are separate.
update.checkOnStart: falseStops automatic update requests and outcome reporting. Explicit updates and other configured services are separate.

OPENCLAW_NO_AUTO_UPDATE=1 also prevents automatic update requests. A truthy CI suppresses both tiers unless a replacement OPENCLAW_TELEMETRY_ENDPOINT is explicitly configured.

openclaw telemetry show displays policy and a CLI-built payload preview, not the exact next Gateway payload or server-derived location information. Registry state and collection time can differ. If policy disables requests, it shows Request: none. Disabling requests does not erase previously recorded rows.

diff --git a/src/update-result.ts b/src/update-result.ts index c5a544c..918e34b 100644 --- a/src/update-result.ts +++ b/src/update-result.ts @@ -2,7 +2,7 @@ import type { DataPoint } from "./analytics.js"; export const MAX_UPDATE_RESULT_BYTES = 4_096; export const UPDATE_RESULT_USER_AGENT = "openclaw-update-result/1"; -const PUBLIC_VERSION = /^202[0-9]\.(?:[1-9]|1[0-2])\.(?:[1-9]|[12][0-9]|3[01])(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/; +const PUBLIC_VERSION = /^202[0-9]\.(?:[1-9]|1[0-2])\.(?:0|[1-9][0-9]{0,5})(?:-[1-9][0-9]{0,2})?(?:-beta\.[1-9][0-9]{0,2})?$/; // Every value is a bounded public label, never an arbitrary diagnostic string. const LABELS = { diff --git a/test/public-surface.test.ts b/test/public-surface.test.ts index 8e0c202..8a04790 100644 --- a/test/public-surface.test.ts +++ b/test/public-surface.test.ts @@ -31,6 +31,10 @@ it.each(["/", "/index.html"])("serves privacy information without a dashboard at const html = await response.text(); expect(html).toContain("How to turn it off"); expect(html).toContain("Approximate location"); + expect(html).toContain("outcomes on by default"); + expect(html).toContain("DO_NOT_TRACK controls feature statistics, not update outcomes"); + expect(html).toContain("Nix mode suppress outcomes"); + expect(html).toContain("before releasing the default-on client"); expect(html).not.toContain("/api/stats"); expect(html).not.toContain("Loading aggregates"); expect(html).not.toContain("