diff --git a/desktop/TELEMETRY.md b/desktop/TELEMETRY.md
new file mode 100644
index 000000000..039e10596
--- /dev/null
+++ b/desktop/TELEMETRY.md
@@ -0,0 +1,51 @@
+# Desktop telemetry
+
+Telemetry is enabled by default. Welcome discloses it without an opt-in gate. Launch OpenBot with either `COPILOTKIT_TELEMETRY_DISABLED=true` or `DO_NOT_TRACK=1` to disable both the desktop emitter and the runtime. Both variables accept `true` or `1`. Quit and relaunch after changing the environment. Opting out removes pending desktop events and prevents recording or replay.
+
+The native emitter starts before credentials are available. It persists a random installation UUID and a bounded queue in the app's local data directory. It does not derive identity from the machine. The runtime receives the same UUID through `CPK_TELEMETRY_ID` and uses sampling rate 1; opt-out still takes precedence.
+
+## Event allowlist
+
+All desktop event names begin with `oss.desktop.`. The Rust `EventData` enum and `schema_json()` define the closed property schema; deserialization rejects unknown properties and enum values.
+
+| Event suffix | Properties |
+| --- | --- |
+| `step_viewed` | Setup step enum |
+| `harness_chosen` | Harness enum, including `byo_url` |
+| `model_chosen` | Provider and credential-path enums; custom-base-URL boolean |
+| `engine_detected` | Engine enum; responding boolean |
+| `engine_installed` | Engine and installer-outcome enums |
+| `windows_stage` | Windows prerequisite outcome enum |
+| `image_pull` | Outcome enum, milliseconds, optional observed download bytes |
+| `setup_failed` | Setup step and error-class enums |
+| `activated` | No properties; first successful setup Bot answer |
+| `setup_abandoned` | Last setup step enum |
+
+Every event carries desktop distribution, numeric app version, platform, architecture, numeric OS version when available, and engine. The runtime receives the same desktop metadata through its existing `telemetryProperties` hook. No prompt, answer, credential, file name, path, hostname, email, model name, YAML value, or custom URL is accepted by the desktop schema.
+
+Image timing covers an explicit missing-only Compose pull, excluding startup and migrations. Byte counts are the download counters observed from Compose, deduplicated per layer; they are not an exact network total. Missing counters remain null. Older Compose providers without `pull --policy missing` retain their existing implicit pull behavior and emit no pull measurement.
+
+## Delivery and verification
+
+Events persist before sending and keep their event IDs across retries. The queue holds at most 256 events, dropping the oldest on overflow. Background delivery, bounded quit flushing, and replay on relaunch use the existing CopilotKit ingest contract. A crash before activation records abandonment on the next launch. Telemetry failures do not block setup.
+
+The ingest must accept the new `oss.desktop.*` namespace: [oss-path-to-production #290](https://github.com/CopilotKit/oss-path-to-production/pull/290). A successful HTTP response alone does not prove downstream acceptance; production ingest acknowledges filtered events too. Merge and deployment of that change are required for production desktop delivery.
+
+Regression tests live in the native telemetry and pull-metrics modules, frontend telemetry tests, and server metadata tests. For an actual local HTTP and installed-runtime check, build the native probe and run the driver from the repository root:
+
+```sh
+cargo build --manifest-path desktop/src-tauri/Cargo.toml --release --example telemetry_probe
+bun desktop/scripts/validate-telemetry.ts desktop/src-tauri/target/release/examples/telemetry_probe
+```
+
+The driver uses temporary data and a loopback receiver. It exercises separate-process persistence/replay, quit, activation deduplication, opt-out, and the installed runtime's identity/metadata handoff. It does not require AI credentials or send validation events to production. Windows and Linux execution remain covered by the platform CI runs; a Mac run is not proof of their native UI behavior.
+
+Local HTTP validation is reusable through `desktop/scripts/validate-telemetry.ts` with the separately built `telemetry_probe` example. It checks offline queue replay with stable installation/event IDs, recovered abandonment, quit flushing, activation once across process restarts, native/runtime metadata, and both `true`/`1` values of `COPILOTKIT_TELEMETRY_DISABLED` and `DO_NOT_TRACK`. Opt-out must remove queued state and identity and produce zero HTTP sends.
+
+The September 12 local run used installed runtime 1.70.1 and its public ESM Hono factory, following the production `eventsource` preload. Its existing Bun dependency layout needed an explicit `NODE_PATH` pointing at the existing hoist directory so a cached `gaxios` module could resolve `extend`. No dependency was installed or modified. Reproduce that adapter from the repository root:
+
+```sh
+NODE_PATH="$PWD/node_modules/.bun/node_modules" bun --no-env-file --no-install desktop/scripts/validate-telemetry.ts /absolute/path/to/telemetry_probe
+```
+
+The final loopback report recorded five native requests and one runtime request with matching installation identity, with all four opt-out cases passing. This covers the emitter and installed-runtime HTTP contract; fresh dependency installation and final PostHog delivery still require their own validation.
diff --git a/desktop/scripts/validate-telemetry.ts b/desktop/scripts/validate-telemetry.ts
new file mode 100644
index 000000000..0346d4425
--- /dev/null
+++ b/desktop/scripts/validate-telemetry.ts
@@ -0,0 +1,416 @@
+/**
+ * Real HTTP validation of the production native emitter and the installed runtime.
+ * Build the native example separately, then run:
+ * bun --no-env-file --no-install desktop/scripts/validate-telemetry.ts /absolute/path/to/telemetry_probe
+ * No credentials, containers, model calls, or production telemetry endpoints are used.
+ */
+import assert from "node:assert/strict";
+import { existsSync } from "node:fs";
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { createRequire } from "node:module";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { desktopTelemetryProperties } from "../../server/src/desktop-telemetry";
+
+const root = resolve(import.meta.dir, "../..");
+const fromServer = createRequire(join(root, "server/package.json"));
+const uuid =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+type Env = Record;
+type Captured = { identity: string | null; body: Record };
+type Queued = { event_id: string; event_name: string; occurred_at_ms: number };
+type State = {
+ install_id: string;
+ queue: Queued[];
+ activated: boolean;
+ last_step: string | null;
+};
+
+function object(value: unknown): Record {
+ assert.ok(value && typeof value === "object" && !Array.isArray(value));
+ return value as Record;
+}
+
+function lastObject(stdout: string): Record {
+ const line = stdout.trim().split("\n").at(-1);
+ assert.ok(line, "child did not return a JSON report");
+ return object(JSON.parse(line));
+}
+
+function loopback(value: string): URL {
+ const url = new URL(value);
+ assert.equal(url.protocol, "http:");
+ assert.equal(url.hostname, "127.0.0.1");
+ return url;
+}
+
+/** Fresh process: the SDK snapshots opt-out and sampling before any runtime import. */
+async function runtimeChild(endpoint: string) {
+ loopback(endpoint);
+ assert.equal(process.env.COPILOTKIT_TELEMETRY_URL, endpoint);
+ const fetchHttp = globalThis.fetch;
+ const sends: Promise[] = [];
+ const blocked: string[] = [];
+ const guardedFetch: typeof fetch = Object.assign(
+ (
+ input: Parameters[0],
+ init?: Parameters[1],
+ ) => {
+ const url = new URL(input instanceof Request ? input.url : String(input));
+ if (url.protocol !== "http:" || url.hostname !== "127.0.0.1") {
+ blocked.push(url.origin);
+ return Promise.reject(new Error("Validation forbids external fetches"));
+ }
+ const result = fetchHttp(input, init);
+ if (url.href === endpoint) sends.push(result);
+ return result;
+ },
+ { preconnect: fetchHttp.preconnect },
+ );
+ globalThis.fetch = guardedFetch;
+
+ const runtimePackage = fromServer("@copilotkit/runtime/package.json");
+ assert.equal(
+ runtimePackage.version,
+ "1.70.1",
+ "recheck the wire contract before changing the installed version",
+ );
+ // Match production-entry.ts's preload and the server's public ESM imports. Requiring the
+ // CJS runtime selects a different dependency graph and does not validate the shipped path.
+ await import(Bun.resolveSync("eventsource", join(root, "server")));
+ const { CopilotRuntime } = await import(
+ Bun.resolveSync("@copilotkit/runtime/v2", join(root, "server"))
+ );
+ const { createCopilotHonoHandler } = await import(
+ Bun.resolveSync("@copilotkit/runtime/v2/hono", join(root, "server"))
+ );
+ const runtime = new CopilotRuntime({
+ agents: {},
+ telemetryProperties: desktopTelemetryProperties(),
+ });
+ const handler = createCopilotHonoHandler({
+ runtime,
+ basePath: "/api/copilotkit",
+ });
+ const info = await handler.fetch(
+ new Request("http://127.0.0.1/api/copilotkit/info"),
+ );
+ assert.equal(info.status, 200);
+ await info.text();
+ // Handler creation queues instance_created in a promise continuation. Drain it before
+ // waiting on the real fetches; no transport is mocked and no polling timeout proves success.
+ await new Promise((done) => setImmediate(done));
+ await Promise.all(sends);
+ assert.deepEqual(blocked, []);
+ console.log(
+ JSON.stringify({
+ runtimeVersion: runtimePackage.version,
+ requests: sends.length,
+ }),
+ );
+}
+
+async function validate(nativePath: string) {
+ assert.ok(
+ existsSync(nativePath),
+ `Build the native telemetry_probe example first: ${nativePath}`,
+ );
+ const scratch = await mkdtemp(
+ join(tmpdir(), "openbot-telemetry-validation-"),
+ );
+ const received: Captured[] = [];
+ const server = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ async fetch(request) {
+ assert.equal(request.method, "POST");
+ assert.equal(new URL(request.url).pathname, "/ingest");
+ const raw = await request.text();
+ assert.ok(Buffer.byteLength(raw) <= 16 * 1024);
+ received.push({
+ identity: request.headers.get("X-CopilotKit-Telemetry-Id"),
+ body: object(JSON.parse(raw)),
+ });
+ return Response.json({ ok: true }, { status: 202 });
+ },
+ });
+ const endpoint = `http://127.0.0.1:${server.port}/ingest`;
+ // Reserve and close a second local socket: a real connection-refused flush, not a fake transport.
+ const offline = Bun.serve({
+ hostname: "127.0.0.1",
+ port: 0,
+ fetch: () => new Response(),
+ });
+ const offlineEndpoint = `http://127.0.0.1:${offline.port}/ingest`;
+ await offline.stop(true);
+
+ const cleanEnv: Env = {
+ PATH: process.env.PATH ?? "",
+ NODE_ENV: "production",
+ NO_COLOR: "1",
+ };
+ // NODE_PATH is an explicit caller-selected resolution aid for existing installations;
+ // this script never installs packages or silently rewires their dependency graph.
+ for (const key of ["SystemRoot", "WINDIR", "TEMP", "TMP", "NODE_PATH"]) {
+ const value = process.env[key];
+ if (value) cleanEnv[key] = value;
+ }
+ async function processRun(command: string[], env: Env, cwd = scratch) {
+ const child = Bun.spawn(command, {
+ cwd,
+ env: { ...cleanEnv, ...env },
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ const timeout = setTimeout(() => child.kill(), 15_000);
+ try {
+ const [stdout, stderr, code] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+ ]);
+ return { stdout, stderr, code };
+ } finally {
+ clearTimeout(timeout);
+ }
+ }
+ function lastJson(stdout: string): Env {
+ const parsed = lastObject(stdout);
+ assert.ok(
+ Object.values(parsed).every((value) => typeof value === "string"),
+ );
+ return parsed as Env;
+ }
+ async function native(
+ directory: string,
+ action: string,
+ env: Env = {},
+ url = endpoint,
+ ) {
+ const result = await processRun([nativePath, directory, url, action], env);
+ assert.equal(
+ result.code,
+ 0,
+ `${action} failed: ${result.stderr.slice(0, 500)}`,
+ );
+ return lastJson(result.stdout);
+ }
+ async function state(directory: string): Promise {
+ return JSON.parse(
+ await readFile(join(directory, "telemetry-state.json"), "utf8"),
+ );
+ }
+ async function runtime(env: Env) {
+ const result = await processRun(
+ [
+ process.execPath,
+ "--no-env-file",
+ "--no-install",
+ import.meta.path,
+ "--runtime-child",
+ endpoint,
+ ],
+ { ...env, COPILOTKIT_TELEMETRY_URL: endpoint },
+ join(root, "server"),
+ );
+ assert.equal(
+ result.code,
+ 0,
+ `runtime child failed: ${result.stderr.slice(0, 1000)}`,
+ );
+ return lastObject(result.stdout);
+ }
+ const checks: string[] = [];
+ try {
+ const recoveredDir = join(scratch, "recovered");
+ const nativeEnv = await native(recoveredDir, "record", {}, offlineEndpoint);
+ const original = await state(recoveredDir);
+ assert.match(original.install_id, uuid);
+ assert.equal(original.queue.length, 1);
+ assert.equal(original.queue[0].event_name, "oss.desktop.step_viewed");
+ assert.equal(nativeEnv.CPK_TELEMETRY_ID, original.install_id);
+ assert.equal(nativeEnv.COPILOTKIT_TELEMETRY_SAMPLE_RATE, "1");
+ const failed = await processRun(
+ [nativePath, recoveredDir, offlineEndpoint, "flush"],
+ {},
+ );
+ assert.notEqual(failed.code, 0, "offline connection must fail");
+ const queued = await state(recoveredDir);
+ assert.equal(queued.install_id, original.install_id);
+ assert.equal(queued.queue.length, 2);
+ assert.equal(queued.queue[0].event_id, original.queue[0].event_id);
+ assert.equal(queued.queue[1].event_name, "oss.desktop.setup_abandoned");
+ assert.equal(received.length, 0);
+ const relaunchedEnv = await native(recoveredDir, "flush");
+ assert.deepEqual(relaunchedEnv, nativeEnv);
+ assert.deepEqual(
+ received.map((request) => request.body.event_id),
+ queued.queue.map((event) => event.event_id),
+ );
+ assert.ok(
+ received.every((request) => request.identity === original.install_id),
+ );
+ assert.deepEqual(
+ received.map((request) => request.body.ts),
+ queued.queue.map((event) => Math.floor(event.occurred_at_ms / 1000)),
+ );
+ assert.equal((await state(recoveredDir)).queue.length, 0);
+ await native(recoveredDir, "flush");
+ assert.equal(
+ received.length,
+ 2,
+ "successful flush must not replay acknowledged events",
+ );
+ checks.push(
+ "offline queue replay: stable installation and event IDs; one recovered abandonment",
+ );
+
+ const quitStart = received.length;
+ await native(join(scratch, "quit"), "quit");
+ assert.deepEqual(
+ received.slice(quitStart).map((request) => request.body.event),
+ ["oss.desktop.step_viewed", "oss.desktop.setup_abandoned"],
+ );
+ checks.push("quit flushes current step and abandonment");
+
+ const activationStart = received.length;
+ const activatedDir = join(scratch, "activated");
+ await native(activatedDir, "activate");
+ await native(activatedDir, "activate");
+ assert.deepEqual(
+ received.slice(activationStart).map((request) => request.body.event),
+ ["oss.desktop.activated"],
+ );
+ assert.equal((await state(activatedDir)).activated, true);
+ checks.push(
+ "activation emitted once across duplicate calls and process relaunch",
+ );
+
+ const nativeRequests = received.length;
+ for (const request of received) {
+ assert.deepEqual(Object.keys(request.body).sort(), [
+ "event",
+ "event_id",
+ "global_properties",
+ "package",
+ "properties",
+ "ts",
+ ]);
+ assert.match(String(request.body.event_id), uuid);
+ assert.deepEqual(request.body.package, {
+ name: "openbot-desktop",
+ version: "0.0.9",
+ });
+ assert.deepEqual(object(request.body.global_properties), {
+ ...desktopTelemetryProperties(nativeEnv),
+ runtime_env: "test",
+ sampleRate: 1,
+ sampleWeight: 1,
+ sampleRateAdjustmentFactor: 0,
+ });
+ }
+ checks.push(
+ "native wire envelope uses closed metadata, seconds and full sampling",
+ );
+
+ const runtimeResult = await runtime({
+ ...nativeEnv,
+ OPENBOT_UNKNOWN: "synthetic-private-value",
+ });
+ assert.equal(runtimeResult.requests, 1);
+ const runtimeRequest = received.at(-1);
+ assert.ok(runtimeRequest);
+ assert.equal(received.length, nativeRequests + 1);
+ assert.equal(runtimeRequest.identity, original.install_id);
+ assert.equal(runtimeRequest.body.event, "oss.runtime.instance_created");
+ assert.deepEqual(runtimeRequest.body.package, {
+ name: "@copilotkit/runtime",
+ version: "1.70.1",
+ });
+ assert.deepEqual(runtimeRequest.body.properties, {
+ actionsAmount: 0,
+ endpointTypes: [],
+ endpointsAmount: 0,
+ agentsAmount: 0,
+ "cloud.api_key_provided": false,
+ });
+ assert.deepEqual(runtimeRequest.body.global_properties, {
+ ...desktopTelemetryProperties(nativeEnv),
+ sampleRate: 1,
+ sampleWeight: 1,
+ sampleRateAdjustmentFactor: 0,
+ telemetry_identified: false,
+ telemetry_emitter: "v2-runtime",
+ telemetry_transport: "lambda",
+ });
+ assert.ok(
+ Number(runtimeRequest.body.ts) > 1_000_000_000 &&
+ Number(runtimeRequest.body.ts) < 10_000_000_000,
+ );
+ checks.push(
+ "installed runtime1.70.1 Hono boot event: same native UUID, closed metadata, full sampling",
+ );
+
+ for (const variable of ["COPILOTKIT_TELEMETRY_DISABLED", "DO_NOT_TRACK"]) {
+ for (const value of ["true", "1"]) {
+ const before: number = received.length;
+ const directory = join(scratch, `${variable}-${value}`);
+ await native(directory, "record", {}, offlineEndpoint);
+ assert.ok((await state(directory)).queue.length > 0);
+ const optedOutEnv = await native(directory, "quit", {
+ [variable]: value,
+ });
+ assert.deepEqual(optedOutEnv, { COPILOTKIT_TELEMETRY_DISABLED: "1" });
+ assert.equal(
+ existsSync(join(directory, "telemetry-state.json")),
+ false,
+ );
+ const fresh = join(scratch, `${variable}-${value}-fresh`);
+ assert.deepEqual(
+ await native(fresh, "env", { [variable]: value }),
+ optedOutEnv,
+ );
+ assert.equal(existsSync(join(fresh, "telemetry-state.json")), false);
+ // Test the SDK's own override too, even if a caller retained an older enabled ID.
+ assert.equal(
+ (await runtime({ ...nativeEnv, [variable]: value })).requests,
+ 0,
+ );
+ assert.equal(received.length, before);
+ checks.push(
+ `${variable}=${value}: queue purged, no identity or HTTP sends, runtime also disabled`,
+ );
+ }
+ }
+ assert.equal(
+ (await runtime({ COPILOTKIT_TELEMETRY_DISABLED: "1" })).requests,
+ 0,
+ );
+ console.log(
+ JSON.stringify(
+ {
+ ok: true,
+ checks,
+ nativeRequests,
+ runtimeRequests: 1,
+ runtimeVersion: "1.70.1",
+ externalRequests: 0,
+ },
+ null,
+ 2,
+ ),
+ );
+ } finally {
+ await server.stop(true);
+ await rm(scratch, { recursive: true, force: true });
+ }
+}
+
+if (process.argv[2] === "--runtime-child") {
+ await runtimeChild(process.argv[3]);
+} else {
+ const nativePath =
+ process.argv[2] ??
+ join(root, "desktop/src-tauri/target/release/examples/telemetry_probe");
+ await validate(resolve(nativePath));
+}
diff --git a/desktop/src-tauri/examples/telemetry_probe.rs b/desktop/src-tauri/examples/telemetry_probe.rs
new file mode 100644
index 000000000..2de34de83
--- /dev/null
+++ b/desktop/src-tauri/examples/telemetry_probe.rs
@@ -0,0 +1,65 @@
+//! Exercise the production emitter against an explicit local receiver, without starting OpenBot.
+use openbot_desktop_lib::telemetry::{
+ Architecture, Config, Context, Distribution, Engine, EventData, NumericVersion, Platform,
+ RuntimeEnv, Step, Telemetry,
+};
+
+fn main() -> Result<(), Box> {
+ let args: Vec = std::env::args().collect();
+ if args.len() != 4 {
+ return Err("usage: telemetry_probe DATA_DIR http://127.0.0.1:PORT/ingest record|flush|quit|activate|env".into());
+ }
+ let url = reqwest::Url::parse(&args[2])?;
+ if url.scheme() != "http" || url.host_str() != Some("127.0.0.1") {
+ return Err("this validation probe only sends to a local HTTP receiver".into());
+ }
+ let telemetry = Telemetry::open(
+ &args[1],
+ Context {
+ distribution: Distribution::Desktop,
+ app_version: NumericVersion::parse("0.0.9")?,
+ platform: if cfg!(target_os = "windows") {
+ Platform::Windows
+ } else if cfg!(target_os = "macos") {
+ Platform::Macos
+ } else {
+ Platform::Linux
+ },
+ arch: if cfg!(target_arch = "aarch64") {
+ Architecture::Aarch64
+ } else {
+ Architecture::X86_64
+ },
+ os_version: None,
+ engine: Engine::Docker,
+ runtime_env: RuntimeEnv::Test,
+ },
+ Config {
+ enabled: true,
+ endpoint: Some(args[2].clone()),
+ max_queue: 256,
+ },
+ )?;
+ match args[3].as_str() {
+ "record" => {
+ telemetry.record(EventData::StepViewed { step: Step::Model })?;
+ // Intentionally leave the process without shutdown to exercise restart recovery.
+ }
+ "flush" => telemetry.flush()?,
+ "quit" => {
+ telemetry.record(EventData::StepViewed {
+ step: Step::Harness,
+ })?;
+ telemetry.shutdown()?;
+ }
+ "activate" => {
+ telemetry.record(EventData::Activated)?;
+ telemetry.record(EventData::Activated)?;
+ telemetry.shutdown()?;
+ }
+ "env" => {}
+ _ => return Err("unknown probe action".into()),
+ }
+ println!("{}", serde_json::to_string(&telemetry.runtime_env())?);
+ Ok(())
+}
diff --git a/desktop/src-tauri/src/desktop_telemetry.rs b/desktop/src-tauri/src/desktop_telemetry.rs
new file mode 100644
index 000000000..d50fb030c
--- /dev/null
+++ b/desktop/src-tauri/src/desktop_telemetry.rs
@@ -0,0 +1,331 @@
+//! Tauri integration for the setup emitter. Network work stays off the event loop.
+use std::collections::BTreeMap;
+use std::sync::{Arc, Mutex};
+
+use openbot_desktop_lib::{engine, quiet, telemetry};
+use tauri::Manager;
+
+pub struct DesktopTelemetry {
+ emitter: Arc,
+ runtime_env: Mutex>,
+}
+
+pub fn initialize(app: &tauri::AppHandle) {
+ let initialized = (|| {
+ let context = context(&app.package_info().version.to_string())?;
+ let emitter = telemetry::Telemetry::open(
+ app.path().app_local_data_dir().ok()?.join("telemetry"),
+ context,
+ telemetry::Config {
+ enabled: true,
+ endpoint: Some(
+ std::env::var("COPILOTKIT_TELEMETRY_URL")
+ .unwrap_or_else(|_| "https://telemetry.copilotkit.ai/ingest".into()),
+ ),
+ max_queue: 256,
+ },
+ )
+ .ok()?;
+ let mut runtime_env = emitter.runtime_env();
+ for variable in [
+ "COPILOTKIT_TELEMETRY_DISABLED",
+ "DO_NOT_TRACK",
+ "COPILOTKIT_TELEMETRY_URL",
+ ] {
+ if let Ok(value) = std::env::var(variable) {
+ runtime_env.insert(variable.into(), value);
+ }
+ }
+ if emitter.install_id().is_none() {
+ runtime_env.insert("COPILOTKIT_TELEMETRY_DISABLED".into(), "1".into());
+ }
+ app.manage(DesktopTelemetry {
+ emitter,
+ runtime_env: Mutex::new(runtime_env),
+ });
+ Some(())
+ })();
+ if initialized.is_none() {
+ eprintln!("[telemetry] local setup telemetry could not initialize");
+ return;
+ }
+ record(
+ app,
+ telemetry::EventData::StepViewed {
+ step: telemetry::Step::Welcome,
+ },
+ );
+}
+
+fn context(version: &str) -> Option {
+ use telemetry::{Architecture, Distribution, Engine, NumericVersion, Platform, RuntimeEnv};
+ let platform = match std::env::consts::OS {
+ "macos" => Platform::Macos,
+ "windows" => Platform::Windows,
+ "linux" => Platform::Linux,
+ _ => Platform::Other,
+ };
+ let mut command = match platform {
+ Platform::Macos => {
+ let mut c = quiet::command("/usr/bin/sw_vers");
+ c.arg("-productVersion");
+ c
+ }
+ Platform::Windows => {
+ let mut c = quiet::command("cmd");
+ c.args(["/C", "ver"]);
+ c
+ }
+ _ => {
+ let mut c = quiet::command("uname");
+ c.arg("-r");
+ c
+ }
+ };
+ let os_version = command
+ .output()
+ .ok()
+ .filter(|output| output.status.success())
+ .and_then(|output| {
+ String::from_utf8_lossy(&output.stdout)
+ .split(|c: char| !c.is_ascii_digit() && c != '.')
+ .find_map(|part| NumericVersion::parse(part).ok())
+ });
+ Some(telemetry::Context {
+ distribution: Distribution::Desktop,
+ app_version: NumericVersion::parse(version.split('-').next()?).ok()?,
+ platform,
+ arch: match std::env::consts::ARCH {
+ "aarch64" => Architecture::Aarch64,
+ "x86_64" => Architecture::X86_64,
+ _ => Architecture::Other,
+ },
+ os_version,
+ engine: Engine::None,
+ runtime_env: if cfg!(debug_assertions) {
+ RuntimeEnv::Development
+ } else {
+ RuntimeEnv::Production
+ },
+ })
+}
+
+pub fn record(app: &tauri::AppHandle, event: telemetry::EventData) {
+ let Some(state) = app.try_state::() else {
+ return;
+ };
+ if state.emitter.record(event).is_err() {
+ eprintln!("[telemetry] setup event could not be saved");
+ return;
+ }
+ let emitter = Arc::clone(&state.emitter);
+ if std::thread::Builder::new()
+ .name("openbot-telemetry".into())
+ .spawn(move || {
+ if emitter.flush().is_err() {
+ eprintln!("[telemetry] delivery deferred until the next flush");
+ }
+ })
+ .is_err()
+ {
+ eprintln!("[telemetry] delivery deferred until the next flush");
+ }
+}
+
+pub fn observe_engine(app: &tauri::AppHandle, status: &engine::EngineStatus) {
+ let engine = status.address.as_ref().map(|address| address.engine);
+ let kind = match engine {
+ Some(engine::Engine::Docker) => telemetry::Engine::Docker,
+ Some(engine::Engine::Podman) => telemetry::Engine::Podman,
+ None => telemetry::Engine::None,
+ };
+ if let Some(state) = app.try_state::() {
+ let _ = state.emitter.update_engine(kind);
+ if let Ok(mut env) = state.runtime_env.lock() {
+ env.insert(
+ "OPENBOT_ENGINE".into(),
+ engine.map(|value| value.binary()).unwrap_or("none").into(),
+ );
+ }
+ }
+ record(
+ app,
+ telemetry::EventData::EngineDetected {
+ engine: kind,
+ responding: status.responding,
+ },
+ );
+}
+
+pub fn runtime_env(app: &tauri::AppHandle) -> BTreeMap {
+ app.try_state::()
+ .and_then(|state| state.runtime_env.lock().ok().map(|env| env.clone()))
+ .unwrap_or_default()
+}
+
+pub fn failure(
+ app: &tauri::AppHandle,
+ error_class: telemetry::SetupErrorClass,
+) {
+ let Some(state) = app.try_state::() else {
+ return;
+ };
+ let step = state
+ .emitter
+ .snapshot()
+ .ok()
+ .and_then(|state| state.last_step)
+ .unwrap_or(telemetry::Step::Install);
+ record(app, telemetry::EventData::SetupFailed { step, error_class });
+}
+
+pub fn pull_completed(
+ app: &tauri::AppHandle,
+ metrics: openbot_desktop_lib::pull_metrics::PullMetrics,
+) {
+ record(
+ app,
+ telemetry::EventData::ImagePull {
+ outcome: metrics.outcome,
+ duration_ms: metrics.duration_ms,
+ bytes: metrics.bytes,
+ },
+ );
+}
+
+pub fn shutdown(app: &tauri::AppHandle) {
+ if let Some(state) = app.try_state::() {
+ if state.emitter.shutdown().is_err() {
+ eprintln!("[telemetry] pending setup events retained for next launch");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::test_support::temp_root;
+ use tauri::Manager;
+
+ fn context() -> telemetry::Context {
+ telemetry::Context {
+ distribution: telemetry::Distribution::Desktop,
+ app_version: telemetry::NumericVersion::parse("1.2.3").unwrap(),
+ platform: telemetry::Platform::Macos,
+ arch: telemetry::Architecture::Aarch64,
+ os_version: None,
+ engine: telemetry::Engine::None,
+ runtime_env: telemetry::RuntimeEnv::Test,
+ }
+ }
+
+ struct Fixture {
+ app: tauri::App,
+ window: tauri::WebviewWindow,
+ }
+
+ impl Fixture {
+ fn new() -> Self {
+ let data_dir = temp_root("desktop-telemetry-ipc");
+ let emitter = telemetry::Telemetry::open_with_env(
+ data_dir,
+ context(),
+ telemetry::Config {
+ enabled: true,
+ endpoint: None,
+ max_queue: 256,
+ },
+ telemetry::EnvOverride::Enabled,
+ )
+ .unwrap();
+ let app = tauri::test::mock_builder()
+ .manage(DesktopTelemetry {
+ emitter,
+ runtime_env: Mutex::new(BTreeMap::new()),
+ })
+ .invoke_handler(tauri::generate_handler![crate::record_setup_event])
+ .build(tauri::test::mock_context(tauri::test::noop_assets()))
+ .unwrap();
+ let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default())
+ .build()
+ .unwrap();
+ Self { app, window }
+ }
+
+ fn invoke(&self, event: serde_json::Value) {
+ let _ = tauri::test::get_ipc_response(
+ &self.window,
+ tauri::webview::InvokeRequest {
+ cmd: "record_setup_event".into(),
+ callback: tauri::ipc::CallbackFn(0),
+ error: tauri::ipc::CallbackFn(1),
+ url: if cfg!(any(windows, target_os = "android")) {
+ "http://tauri.localhost"
+ } else {
+ "tauri://localhost"
+ }
+ .parse()
+ .unwrap(),
+ body: tauri::ipc::InvokeBody::Json(serde_json::json!({ "event": event })),
+ headers: Default::default(),
+ invoke_key: tauri::test::INVOKE_KEY.into(),
+ },
+ );
+ }
+
+ fn queued(&self) -> Vec {
+ self.app
+ .state::()
+ .emitter
+ .snapshot()
+ .unwrap()
+ .queue
+ .into_iter()
+ .map(|event| event.data)
+ .collect()
+ }
+ }
+
+ #[test]
+ fn setup_telemetry_ipc_accepts_only_frontend_setup_events() {
+ let fixture = Fixture::new();
+
+ fixture.invoke(serde_json::json!({
+ "kind": "step_viewed",
+ "step": "harness"
+ }));
+ fixture.invoke(serde_json::json!({
+ "kind": "harness_chosen",
+ "harness": "byo_url"
+ }));
+ assert_eq!(fixture.queued().len(), 2);
+
+ fixture.invoke(serde_json::json!({
+ "kind": "harness_chosen",
+ "harness": "byo_url",
+ "url": "https://example.invalid"
+ }));
+ fixture.invoke(serde_json::json!({
+ "kind": "step_viewed",
+ "step": "credentials"
+ }));
+ fixture.invoke(serde_json::json!({
+ "kind": "activated"
+ }));
+
+ let queued = fixture.queued();
+ assert_eq!(queued.len(), 2);
+ assert!(matches!(
+ queued[0],
+ telemetry::EventData::StepViewed {
+ step: telemetry::Step::Harness
+ }
+ ));
+ assert!(matches!(
+ queued[1],
+ telemetry::EventData::HarnessChosen {
+ harness: telemetry::Harness::ByoUrl
+ }
+ ));
+ }
+}
diff --git a/desktop/src-tauri/src/install.rs b/desktop/src-tauri/src/install.rs
index 254ae1b1a..17a18594d 100644
--- a/desktop/src-tauri/src/install.rs
+++ b/desktop/src-tauri/src/install.rs
@@ -208,6 +208,14 @@ fn digest_of(bytes: &[u8]) -> String {
/// somebody presses Start. Answers with the sentence for the step row, or a failure in both
/// registers.
pub fn install_engine(cache: &Path) -> Result {
+ install_engine_observed(cache, |_| {})
+}
+
+/// Observe an actual Podman installer invocation, excluding existing engines and Compose repair.
+pub fn install_engine_observed(
+ cache: &Path,
+ mut installed: impl FnMut(bool),
+) -> Result {
let into = crate::acquire::download_dir(cache);
// An engine somebody already has is theirs. This only ever adds what is missing.
@@ -215,7 +223,9 @@ pub fn install_engine(cache: &Path) -> Result {
return place_compose(&into);
}
- install_podman(&into)?;
+ let result = install_podman(&into);
+ installed(result.is_ok());
+ result?;
// Installed is not found. The MSI extends the *user's* PATH and this process was started with
// the old one, so the engine is looked for where the installer puts it rather than on PATH. If
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 7b960bf54..48950481d 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -11,10 +11,12 @@ pub mod intelligence;
pub mod plan;
pub mod problem;
pub mod provider;
+pub mod pull_metrics;
pub mod quiet;
pub mod saved_intent;
pub mod stack;
pub mod supervise;
+pub mod telemetry;
pub mod tray;
pub mod vault;
pub mod windows;
diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs
index 52d0534ef..5d20282ba 100644
--- a/desktop/src-tauri/src/main.rs
+++ b/desktop/src-tauri/src/main.rs
@@ -4,12 +4,15 @@
use std::path::{Path, PathBuf};
use std::sync::Mutex;
+mod desktop_telemetry;
+
#[cfg(test)]
mod test_support;
use openbot_desktop_lib::{
acquire, deployment, deployment_release, engine, env as openbot_env, harness, install,
- problem::Problem, provider, quiet, stack, supervise, tray, windows as win,
+ problem::Problem, provider, pull_metrics, quiet, stack, supervise, telemetry, tray,
+ windows as win,
};
const QUIT_CLEANUP_NOTICE_FILE: &str = ".openbot-quit-cleanup-notice";
@@ -259,6 +262,17 @@ fn report(
ok: bool,
detail: impl Into,
) {
+ if !ok {
+ let error_class = match step {
+ "install-engine" => telemetry::SetupErrorClass::EngineInstallFailed,
+ "engine" | "create-machine" | "start-machine" | "health-gate" => {
+ telemetry::SetupErrorClass::EngineUnavailable
+ }
+ "env" | "ports" => telemetry::SetupErrorClass::InvalidConfiguration,
+ _ => telemetry::SetupErrorClass::Unknown,
+ };
+ desktop_telemetry::failure(app, error_class);
+ }
let _ = app.emit(
"setup:progress",
Progress {
@@ -292,13 +306,46 @@ fn cleanup_root(shell: &Shell, fallback_root: &Path) -> PathBuf {
}
#[tauri::command]
-fn detect_engine() -> engine::EngineStatus {
- engine::detect()
+fn detect_engine(app: tauri::AppHandle) -> engine::EngineStatus {
+ let status = engine::detect();
+ desktop_telemetry::observe_engine(&app, &status);
+ status
+}
+
+#[tauri::command]
+fn windows_blocker(
+ app: tauri::AppHandle,
+) -> Result
+
+ OpenBot sends setup and usage statistics to CopilotKit by default: a
+ random installation ID, setup steps, platform and engine details,
+ download performance, and Bot and connection choices. Telemetry does not
+ include prompts, files, credentials, email addresses, or server URLs. To
+ turn it off, launch OpenBot with COPILOTKIT_TELEMETRY_DISABLED=1 or
+ DO_NOT_TRACK=1.
+