From 6885eb69b02bc16d08c1403343b83c8980065ed4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 01:37:48 -0700 Subject: [PATCH] fix(telemetry): suppress reporting from automated environments (#129155) CI jobs are not installs; unchecked they outnumber operators by orders of magnitude and make version and platform counts meaningless. A configured telemetry endpoint still reports, so update-path E2E lanes keep working. --- docs/gateway/telemetry.md | 13 +++++++++++++ src/cli/telemetry-cli.ts | 1 + src/infra/telemetry.test.ts | 36 +++++++++++++++++++++++++++++++++++- src/infra/telemetry.ts | 19 ++++++++++++++++++- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/docs/gateway/telemetry.md b/docs/gateway/telemetry.md index 301f1aba341d..e7f4ad707d6d 100644 --- a/docs/gateway/telemetry.md +++ b/docs/gateway/telemetry.md @@ -165,6 +165,19 @@ even when `telemetry.enabled` is `true`. `DO_NOT_TRACK` does not disable the daily update check: OpenClaw sends the update-only `GET` request without a feature-statistics body. +## Automated environments + +OpenClaw sends nothing when it detects an automated environment, meaning the +`CI` environment variable is set to a truthy value. Continuous integration jobs +are not installations: they would outnumber real operators by orders of +magnitude and make version and platform counts meaningless, and your pipeline +should not report to us on every job. + +This applies to both tiers, so a CI job sends no update check and no feature +statistics. Setting `OPENCLAW_TELEMETRY_ENDPOINT` overrides the suppression, +because a configured endpoint means the run is deliberately exercising this +path. + ## Disable every automatic update request To go fully dark, disable the existing startup update check: diff --git a/src/cli/telemetry-cli.ts b/src/cli/telemetry-cli.ts index 154dcdb14013..13ee9b4c32b6 100644 --- a/src/cli/telemetry-cli.ts +++ b/src/cli/telemetry-cli.ts @@ -10,6 +10,7 @@ import { runCommandWithRuntime } from "./cli-utils.js"; const TELEMETRY_REASON_LABELS = { enabled: "enabled in configuration", + "automated-environment": "disabled in an automated environment (CI is set)", "do-not-track": "disabled by DO_NOT_TRACK", "config-disabled": "disabled in configuration", "never-asked": "consent has not been requested", diff --git a/src/infra/telemetry.test.ts b/src/infra/telemetry.test.ts index c48a8d13a596..620c4d84f52f 100644 --- a/src/infra/telemetry.test.ts +++ b/src/infra/telemetry.test.ts @@ -12,7 +12,11 @@ import { type OpenClawTestState, } from "../test-utils/openclaw-test-state.js"; import { VERSION } from "../version.js"; -import { buildTelemetryPayload, checkTelemetryUpdate } from "./telemetry.js"; +import { + buildTelemetryPayload, + checkTelemetryUpdate, + resolveTelemetryStatus, +} from "./telemetry.js"; const NOW = Date.parse("2026-08-23T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1000; @@ -86,6 +90,7 @@ describe("anonymous telemetry", () => { layout: "state-only", prefix: "openclaw-telemetry-", env: { + CI: undefined, DO_NOT_TRACK: undefined, OPENCLAW_NIX_MODE: undefined, OPENCLAW_NO_AUTO_UPDATE: undefined, @@ -360,6 +365,35 @@ describe("anonymous telemetry", () => { expect(readConfigMachineState(TELEMETRY_STATE_KEY)).toBeUndefined(); }); + it("never sends a request from an automated environment", async () => { + setTestEnvValue("CI", "true"); + + await expect( + checkTelemetryUpdate(createFeatureConfig(), { + surface: "gateway", + fetchImpl: globalThis.fetch, + nowMs: NOW, + }), + ).resolves.toBeNull(); + + expect(mockHttp.requests()).toHaveLength(0); + expect(readConfigMachineState(TELEMETRY_STATE_KEY)).toBeUndefined(); + expect(resolveTelemetryStatus(createFeatureConfig()).reason).toBe("automated-environment"); + }); + + it("still reports from an automated environment when an endpoint is configured for it", async () => { + const customEndpoint = "https://telemetry.example.invalid/api/latest-version"; + setTestEnvValue("CI", "true"); + setTestEnvValue("OPENCLAW_TELEMETRY_ENDPOINT", customEndpoint); + mockHttp.intercept({ url: customEndpoint, reply: { json: { version: "2026.8.24" } } }); + + await expect( + checkTelemetryUpdate({}, { surface: "gateway", fetchImpl: globalThis.fetch, nowMs: NOW }), + ).resolves.toEqual({ version: "2026.8.24" }); + + expect(mockHttp.requests()).toHaveLength(1); + }); + it("never sends a request for Nix-managed installations", async () => { setTestEnvValue("OPENCLAW_NIX_MODE", "1"); diff --git a/src/infra/telemetry.ts b/src/infra/telemetry.ts index fb799a9ff330..c7760f4f23c9 100644 --- a/src/infra/telemetry.ts +++ b/src/infra/telemetry.ts @@ -54,6 +54,7 @@ type TelemetryPayload = { type TelemetryStatusReason = | "enabled" + | "automated-environment" | "do-not-track" | "config-disabled" | "never-asked" @@ -73,10 +74,24 @@ const TelemetryResponseSchema = z.object({ let lastFailedAttempt: { at: number; endpoint: string; stateDirectory?: string } | undefined; let inFlightUpdate: Promise | undefined; +/** + * CI jobs are not installs. Left unchecked they outnumber operators by orders of + * magnitude and make version and platform counts meaningless, and someone else's + * pipeline should not report to us on every job either. A configured endpoint + * means the caller is deliberately exercising this path, so it still reports. + */ +function isAutomatedEnvironment(): boolean { + if (process.env.OPENCLAW_TELEMETRY_ENDPOINT?.trim()) { + return false; + } + return isTruthyEnvValue(process.env.CI); +} + function isUpdateCheckDisabled(config: OpenClawConfig): boolean { return ( config.update?.checkOnStart === false || isTruthyEnvValue(process.env.OPENCLAW_NO_AUTO_UPDATE) || + isAutomatedEnvironment() || resolveIsNixMode() ); } @@ -132,7 +147,9 @@ export function resolveTelemetryStatus(config: OpenClawConfig): { lastPingAt?: number; } { let reason: TelemetryStatusReason; - if (isUpdateCheckDisabled(config)) { + if (isAutomatedEnvironment()) { + reason = "automated-environment"; + } else if (isUpdateCheckDisabled(config)) { reason = "update-disabled"; } else if (isDoNotTrackEnabled()) { reason = "do-not-track";