diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock index 519df2af36..39297031a4 100644 --- a/libs/cua-driver-rs/Cargo.lock +++ b/libs/cua-driver-rs/Cargo.lock @@ -134,6 +134,35 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -230,6 +259,8 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ureq", + "uuid", ] [[package]] @@ -258,6 +289,15 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -268,6 +308,26 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -281,7 +341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -397,6 +457,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -521,12 +590,115 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -619,6 +791,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -680,7 +864,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -716,9 +900,15 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + [[package]] name = "num-traits" version = "0.2.19" @@ -899,6 +1089,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pico-args" version = "0.5.0" @@ -1011,6 +1207,21 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1139,6 +1350,20 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -1155,7 +1380,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1188,7 +1448,7 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1354,9 +1614,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strict-num" version = "0.1.1" @@ -1366,6 +1632,12 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "svgtypes" version = "0.15.3" @@ -1387,6 +1659,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1397,7 +1680,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1429,6 +1712,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -1455,6 +1769,16 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1484,7 +1808,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1665,6 +1989,56 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "cookie_store", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "usvg" version = "0.44.0" @@ -1698,6 +2072,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "1.23.1" @@ -1830,6 +2216,15 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows" version = "0.58.0" @@ -1900,6 +2295,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2067,6 +2471,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "x11rb" version = "0.13.2" @@ -2090,6 +2500,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -2110,6 +2543,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md index 8c71dc8803..dfc5df0dc9 100644 --- a/libs/cua-driver-rs/PARITY.md +++ b/libs/cua-driver-rs/PARITY.md @@ -1660,3 +1660,118 @@ side-effect without per-binary special-casing. uses `Task.sleep`; Rust's blocking `std::thread::sleep` is cheap to offload via `tokio::task::spawn_blocking` and keeps the runtime responsive to other in-flight work. + +--- + +## Telemetry (PostHog) + +- Swift: `libs/cua-driver/Sources/CuaDriverCore/Telemetry/TelemetryClient.swift` +- Rust: `crates/cua-driver/src/telemetry.rs` +- Status: VERIFIED (events emit on entry-point dispatch + install) +- Test: `crates/cua-driver/src/telemetry.rs` `#[cfg(test)] mod tests` + (8 unit tests: env parsing, opt-out default, CI detection, + payload shape, payload-key collision, install-id idempotent + persistence, ISO-8601 format, arch mapping) + +### Endpoint + event names (identical to Swift) + +- POST `https://eu.i.posthog.com/capture/` +- API key: `phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14` (public — + ingest-only, can't read events) +- Events: `cua_driver_install`, `cua_driver_mcp`, `cua_driver_serve`, + `cua_driver_stop`, `cua_driver_status`, `cua_driver_list_tools`, + `cua_driver_describe`, `cua_driver_recording`, `cua_driver_config`, + `cua_driver_mcp_config`, `cua_driver_dump_docs`, `cua_driver_update`, + `cua_driver_doctor`, `cua_driver_diagnose`, + `cua_driver_api_` (per-tool `call` invocations). + +Keeping the endpoint + names identical means Rust + Swift events land +in the same PostHog project; `$lib = "cua-driver-rs"` vs +`"cua-driver-swift"` is the only signal to split them. + +### Payload shape + +Each event sends: + +| Key | Value | Source | +|-----|-------|--------| +| `cua_driver_version` | CARGO_PKG_VERSION (e.g. `"0.1.3"`) | build-time | +| `os` | `"macos"` / `"linux"` / `"windows"` | `std::env::consts::OS` | +| `os_version` | OS-reported version string | `sw_vers -productVersion` / `/etc/os-release` / `cmd /c ver` | +| `arch` | `"arm64"` / `"x86_64"` (aarch64 → arm64) | `std::env::consts::ARCH` | +| `is_ci` | bool | env-var probe (see below) | +| `$lib` | `"cua-driver-rs"` | hard-coded | +| `$lib_version` | CARGO_PKG_VERSION | build-time | + +CI-environment detection probes the same vars Swift does: `CI`, +`CONTINUOUS_INTEGRATION`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL`, +`CIRCLECI`. + +### Privacy posture (what we DO NOT send) + +Verified by unit test `build_payload_contains_required_keys` which +asserts none of `$user`, `username`, `home_dir`, `cwd`, `argv` ever +appear in a serialized payload: + +- No usernames or `$USER` / `$HOME` +- No file paths (cwd, executable path, tool args, screenshot paths) +- No tool arguments — `call ` reports only the tool name as + `cua_driver_api_` +- No user-typed content (text, key sequences, URLs) +- No window titles, application names, or coordinates + +### Opt-out + +Set `CUA_DRIVER_RS_TELEMETRY_ENABLED=false` (or `0`, `no`, `off`) to +disable ALL telemetry from the binary. Unset defaults to enabled +(matches Swift's persisted-flag default of `true`). + +The **only** path that ignores the opt-out is `capture_install()`, +which fires the one-shot `cua_driver_install` ping from `install.sh`'s +post-install hook. Rationale: an opt-out user is still a counted +install in the adoption metric; every subsequent event from the binary +respects the flag normally. Guarded by `~/.cua-driver-rs/.installation_recorded` +so re-running `install.sh` doesn't re-fire it. + +### Independence from Swift install + +The Rust port is deliberately partitioned from the Swift port at the +filesystem + env-var layer: + +| Layer | Swift | Rust | +|-------|-------|------| +| Install dir | `~/.cua-driver/` | `~/.cua-driver-rs/` | +| Install UUID | `~/.cua-driver/.telemetry_id` | `~/.cua-driver-rs/.telemetry_id` | +| Install marker | `~/.cua-driver/.installation_recorded` | `~/.cua-driver-rs/.installation_recorded` | +| Opt-out env var | `CUA_DRIVER_TELEMETRY_ENABLED` | `CUA_DRIVER_RS_TELEMETRY_ENABLED` | + +This means: +- A machine with both installed shows up as two distinct adoption events + (we can count Rust adoption separately from Swift). +- A user who opts out of one port stays opted-in for the other unless + they set both env vars. +- The Rust port can ship telemetry changes without invalidating Swift's + install UUID (no shared on-disk state). + +### HTTP client + +`ureq` v3 with default features (rustls + gzip + json). Single POST, +3-second timeout, fire-and-forget. Sent from `tokio::task::spawn_blocking` +when a runtime is live (MCP server, serve daemon), else from a +short-lived OS thread (synchronous CLI subcommands like `list-tools`). + +Network errors, timeouts, and 4xx/5xx responses are logged via +`tracing::debug!(target: "cua_driver::telemetry", …)` only — never +surfaced to stdout/stderr unless `CUA_DRIVER_RS_TELEMETRY_DEBUG=true`. + +### Intentional divergences from Swift + +- **No persisted config flag.** Swift falls back to a YAML + `telemetryEnabled` setting via `ConfigStore.loadSync()`. Rust honors + only the env var. The Rust port has no `ConfigStore` analogue yet, and + YAGNI suggests waiting until someone files a request. +- **No `GUI_LAUNCH` emission.** Swift fires `cua_driver_gui_launch` when + the binary is launched bare (Finder / Dock double-click). Rust has no + GUI surface yet, so the constant is reserved but unused. +- **`is_ci` uses env-var probing only.** Same probe list as Swift; no + extra Rust-specific signals. diff --git a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml index 34cc6461cf..23e51590c3 100644 --- a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml +++ b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml @@ -20,6 +20,11 @@ mcp-server = { path = "../mcp-server" } cursor-overlay = { path = "../cursor-overlay" } async-trait = "0.1" base64 = { workspace = true } +uuid = { workspace = true } +# Telemetry HTTP client. `ureq` over `reqwest` for the small dep footprint: +# PostHog ingest is a single fire-and-forget POST with a 3s timeout. Uses +# rustls (default) so Linux/Windows builds don't require system OpenSSL. +ureq = { version = "3", features = ["json"] } # Used by crate::bundle::parent_is_not_launchd() for the TCC # auto-relaunch detection path on Unix (only the macOS heuristic diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs index b476174c72..13bba63c67 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs @@ -58,6 +58,13 @@ pub enum Command { value: Option, socket: Option, }, + /// Hidden subcommand used by `scripts/install.sh` to emit the + /// one-shot `cua_driver_install` PostHog event. Bypasses the + /// opt-out flag (the only call site that does so) so we get a + /// usable adoption signal even from users who opt out immediately + /// after install. Subsequent runs see the `.installation_recorded` + /// marker file and become no-ops. + TelemetryInstallEvent, } /// Flags whose next token is a value (not a subcommand). @@ -162,6 +169,17 @@ pub fn parse_command() -> Command { .or_else(|| read_stdin_json()); Command::Call { tool, json_args, screenshot_out_file } } + Some("telemetry") => { + // Hidden — used by install.sh. Only supports `install-event` + // today; left extensible (e.g. future `telemetry status`). + match pos.next() { + Some("install-event") => Command::TelemetryInstallEvent, + _ => { + eprintln!("Usage: cua-driver telemetry install-event"); + process::exit(64); + } + } + } Some(first) => { // Implicit call: unrecognised first positional → treat as tool name. let tool = first.to_string(); @@ -1453,6 +1471,130 @@ fn read_stdin_json() -> Option { serde_json::from_str(buf.trim()).ok() } +/// Map a parsed [`Command`] to its canonical telemetry event name. +/// +/// Mirrors Swift's `CuaDriverCommand.telemetryEntryEvent(for:)`. Per-tool +/// `call ` invocations report as `cua_driver_api_` so per-tool +/// usage shows up in aggregate without our ever recording the args. +/// +/// Returns `None` for [`Command::TelemetryInstallEvent`] — that path emits +/// the install event directly via [`crate::telemetry::capture_install`] +/// instead of a per-entry event. +pub fn telemetry_entry_event(cmd: &Command) -> Option { + use crate::telemetry::event; + let name = match cmd { + Command::Mcp { .. } => event::MCP.to_owned(), + Command::Serve { .. } => event::SERVE.to_owned(), + Command::Stop { .. } => event::STOP.to_owned(), + Command::Status { .. } => event::STATUS.to_owned(), + Command::ListTools => event::LIST_TOOLS.to_owned(), + Command::Describe(_) => event::DESCRIBE.to_owned(), + // `call ` → per-tool event (no args, just the tool name). + // The tool name flows into the PostHog event name, so we sanitize + // it aggressively (see `sanitize_tool_name`) before concatenation — + // otherwise weird / path-like / non-ASCII tool names would pollute + // dashboards and could even leak user-controlled strings into + // telemetry event names. + Command::Call { tool, .. } => { + if tool.is_empty() { + event::CALL.to_owned() + } else { + format!("{}{}", event::API_PREFIX, sanitize_tool_name(tool)) + } + } + Command::McpConfig { .. } => "cua_driver_mcp_config".to_owned(), + Command::Recording { .. } => event::RECORDING.to_owned(), + Command::Config { .. } => event::CONFIG.to_owned(), + Command::DumpDocs { .. } => "cua_driver_dump_docs".to_owned(), + Command::Update { .. } => "cua_driver_update".to_owned(), + Command::Doctor => "cua_driver_doctor".to_owned(), + Command::Diagnose => "cua_driver_diagnose".to_owned(), + Command::TelemetryInstallEvent => return None, + }; + Some(name) +} + +/// Normalise a user-provided tool name into a safe PostHog event suffix. +/// +/// Tool names are concatenated onto `cua_driver_api_` to build per-tool +/// telemetry event names. The raw string is user-controlled (any CLI +/// arg or MCP request can specify it), so we: +/// +/// 1. ASCII-lowercase +/// 2. Keep only `[a-z0-9_]` — drop punctuation, slashes, dots, anything else +/// 3. Truncate to 64 chars (event names are a dashboard axis, not free text) +/// 4. Fall back to `"unknown"` when the result is empty (e.g. all non-ASCII +/// input), so we still record *that* a call happened without inventing +/// a per-payload event name. +fn sanitize_tool_name(name: &str) -> String { + const MAX_LEN: usize = 64; + const FALLBACK: &str = "unknown"; + + let cleaned: String = name + .chars() + .filter_map(|c| { + let lc = c.to_ascii_lowercase(); + if lc.is_ascii_alphanumeric() || lc == '_' { + Some(lc) + } else { + None + } + }) + .take(MAX_LEN) + .collect(); + + if cleaned.is_empty() { + FALLBACK.to_owned() + } else { + cleaned + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_tool_name_passes_through_canonical_names() { + assert_eq!(sanitize_tool_name("click"), "click"); + assert_eq!(sanitize_tool_name("move_mouse"), "move_mouse"); + assert_eq!(sanitize_tool_name("ScrollUp"), "scrollup"); + } + + #[test] + fn sanitize_tool_name_strips_punctuation_and_path_separators() { + // Path-like input would otherwise leak directory names into event + // names — strip everything that's not [a-z0-9_]. + assert_eq!(sanitize_tool_name("foo.bar/baz"), "foobarbaz"); + assert_eq!(sanitize_tool_name("../etc/passwd"), "etcpasswd"); + assert_eq!(sanitize_tool_name("click-element!"), "clickelement"); + } + + #[test] + fn sanitize_tool_name_falls_back_when_non_ascii() { + // Non-ASCII characters are dropped entirely — without a fallback + // we'd emit `cua_driver_api_` (empty suffix), which collides with + // the bare `cua_driver_call` event. + assert_eq!(sanitize_tool_name("クリック"), "unknown"); + assert_eq!(sanitize_tool_name("🚀"), "unknown"); + } + + #[test] + fn sanitize_tool_name_falls_back_on_empty_or_all_stripped() { + assert_eq!(sanitize_tool_name(""), "unknown"); + assert_eq!(sanitize_tool_name("---"), "unknown"); + assert_eq!(sanitize_tool_name("///"), "unknown"); + } + + #[test] + fn sanitize_tool_name_caps_length_at_64() { + let long_name = "a".repeat(200); + let sanitized = sanitize_tool_name(&long_name); + assert_eq!(sanitized.len(), 64); + assert!(sanitized.chars().all(|c| c == 'a')); + } +} + fn first_sentence(text: &str) -> String { let trimmed = text.trim(); if trimmed.is_empty() { return String::new(); } diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs index 5a986b47db..d7b752f5b4 100644 --- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs +++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs @@ -28,6 +28,7 @@ mod bundle; mod cli; mod proxy; mod serve; +mod telemetry; use std::sync::Arc; @@ -42,6 +43,21 @@ fn init_logging() { .init(); } +/// Fire the per-entry-point telemetry event (e.g. `cua_driver_mcp`, +/// `cua_driver_api_click`). Respects the opt-out env var — no-op when +/// telemetry is disabled. Always returns immediately: the actual POST +/// happens on a background thread or tokio task. +/// +/// Mirrors Swift's `TelemetryClient.shared.record(event:)` call at the +/// top of `CuaDriverCommand.main()`. The install ping is *not* emitted +/// here — that's the dedicated `telemetry install-event` subcommand +/// fired exactly once by the post-install script. +fn emit_entry_telemetry(command: &cli::Command) { + if let Some(event_name) = cli::telemetry_entry_event(command) { + telemetry::capture(&event_name, None); + } +} + // ── macOS entry-point ───────────────────────────────────────────────────── #[cfg(target_os = "macos")] @@ -51,7 +67,18 @@ fn main() { // ── CLI subcommand dispatch ────────────────────────────────────────────── // Handled before AppKit init so `list-tools` / `describe` / `call` exit // cleanly without starting the overlay or NSApplication. - match cli::parse_command() { + let command = cli::parse_command(); + emit_entry_telemetry(&command); + match command { + cli::Command::TelemetryInstallEvent => { + // Synchronous install ping (see `telemetry::capture_install`). + // Blocks on the POST so the `.installation_recorded` marker + // is only written on HTTP success — failed POST means next + // launch retries. Installer script already runs us in the + // background via `&`, so blocking here is fine. + telemetry::capture_install(); + return; + } cli::Command::ListTools => { let reg = Arc::new(platform_macos::register_tools()); cli::run_list_tools(®); @@ -265,7 +292,18 @@ fn main() -> anyhow::Result<()> { // These commands create their own tokio runtimes internally, so they must // run on a plain OS thread — not inside a #[tokio::main] context which // would cause nested block_on panics. - match cli::parse_command() { + let command = cli::parse_command(); + emit_entry_telemetry(&command); + match command { + cli::Command::TelemetryInstallEvent => { + // Synchronous install ping (see `telemetry::capture_install`). + // Blocks on the POST so the `.installation_recorded` marker + // is only written on HTTP success — failed POST means next + // launch retries. Installer script already runs us in the + // background via `&`, so blocking here is fine. + telemetry::capture_install(); + return Ok(()); + } cli::Command::ListTools => { let reg = Arc::new(build_registry_no_cursor()); cli::run_list_tools(®); diff --git a/libs/cua-driver-rs/crates/cua-driver/src/telemetry.rs b/libs/cua-driver-rs/crates/cua-driver/src/telemetry.rs new file mode 100644 index 0000000000..98e6b3e345 --- /dev/null +++ b/libs/cua-driver-rs/crates/cua-driver/src/telemetry.rs @@ -0,0 +1,757 @@ +//! Anonymous usage-tracking client — a Rust port of the Swift +//! `TelemetryClient` (`libs/cua-driver/Sources/CuaDriverCore/Telemetry/ +//! TelemetryClient.swift`). +//! +//! Same PostHog HTTP-capture pattern, per-install UUID, env-override +//! support, and installation-record-once behavior as the Swift reference. +//! +//! ## Differences from Swift +//! +//! - **Install ID path** is `~/.cua-driver-rs/.telemetry_id` (Swift uses +//! `~/.cua-driver/.telemetry_id`). Deliberately independent so a user +//! who opts out of one binary still gets a fresh distinct_id on the other. +//! - **Opt-out env var** is `CUA_DRIVER_RS_TELEMETRY_ENABLED=false` (Swift +//! uses `CUA_DRIVER_TELEMETRY_ENABLED`). Same reason — opting out of one +//! port must not silence the other. +//! - **`$lib`** is reported as `cua-driver-rs` so dashboards can split +//! Rust vs Swift adoption per-event without inspecting `os`/`arch`. +//! - **No persisted config flag.** Swift falls back to a YAML +//! `telemetryEnabled` setting; Rust honours only the env var (telemetry +//! defaults to enabled, env-var opt-out). Matches Rust's existing +//! config surface — there's no `ConfigStore.loadSync()` analogue yet, +//! and YAGNI suggests waiting until someone actually requests it. +//! +//! ## Privacy posture (identical to Swift) +//! +//! We send: driver version, OS name, OS version, CPU arch, CI-environment +//! flag, and a stable per-install UUID. We do **NOT** send: usernames, +//! file paths, command arguments, tool args, or anything user-typed. + +use std::path::PathBuf; +use std::sync::OnceLock; + +// ── Constants ──────────────────────────────────────────────────────────── + +/// PostHog ingest endpoint (EU region — matches Swift exactly so dashboards +/// aggregate Rust + Swift events cleanly). +const POSTHOG_CAPTURE_URL: &str = "https://eu.i.posthog.com/capture/"; + +/// Public PostHog project key. Public by design — keys can only ingest +/// events, not read them. Matches Swift `TelemetryClient.Constants.apiKey`. +const POSTHOG_API_KEY: &str = "phc_eSkLnbLxsnYFaXksif1ksbrNzYlJShr35miFLDppF14"; + +/// `~/.cua-driver-rs/` subdirectory (Rust-specific — Swift uses `.cua-driver`). +const HOME_SUBDIRECTORY: &str = ".cua-driver-rs"; + +/// Filename inside the home subdirectory holding the per-install UUID. +const TELEMETRY_ID_FILE_NAME: &str = ".telemetry_id"; + +/// Marker file written after the install event has been recorded once. +const INSTALLATION_RECORDED_FILE_NAME: &str = ".installation_recorded"; + +/// Env var disabling telemetry. Accepts `0|false|no|off` to disable, +/// `1|true|yes|on` to enable. Default: enabled (matches Swift). +const ENV_TELEMETRY_ENABLED: &str = "CUA_DRIVER_RS_TELEMETRY_ENABLED"; + +/// Env var enabling debug logging to stderr (mirrors Swift +/// `CUA_DRIVER_TELEMETRY_DEBUG`). +const ENV_TELEMETRY_DEBUG: &str = "CUA_DRIVER_RS_TELEMETRY_DEBUG"; + +/// Fire-and-forget POST timeout. PostHog ingest must never delay a CLI call. +const POSTHOG_TIMEOUT_SECS: u64 = 3; + +// ── Canonical event names ──────────────────────────────────────────────── + +/// Event names, mirrored 1:1 from Swift `TelemetryEvent`. Keeping the +/// exact strings is load-bearing for dashboards (Rust + Swift aggregate +/// under the same event name). +pub mod event { + /// One-time install ping. Sent regardless of opt-out (see + /// `capture_install`); all other events respect the env flag. + pub const INSTALL: &str = "cua_driver_install"; + + // CLI entry points + pub const MCP: &str = "cua_driver_mcp"; + pub const SERVE: &str = "cua_driver_serve"; + pub const STOP: &str = "cua_driver_stop"; + pub const STATUS: &str = "cua_driver_status"; + pub const CALL: &str = "cua_driver_call"; + pub const LIST_TOOLS: &str = "cua_driver_list_tools"; + pub const DESCRIBE: &str = "cua_driver_describe"; + pub const RECORDING: &str = "cua_driver_recording"; + pub const CONFIG: &str = "cua_driver_config"; + /// Bare-launch (no args) event — Swift uses it from + /// `runFirstLaunchGUI`. Rust has no GUI surface yet but ships the + /// constant for future parity. + #[allow(dead_code)] + pub const GUI_LAUNCH: &str = "cua_driver_gui_launch"; + + /// Prefix for per-MCP-tool events. `API_PREFIX + tool_name` produces + /// e.g. `cua_driver_api_click`. + pub const API_PREFIX: &str = "cua_driver_api_"; +} + +// ── Public API ─────────────────────────────────────────────────────────── + +/// Whether telemetry is enabled in this process. Env var is the only +/// signal in the Rust port (no persisted config flag — see module docs). +/// +/// Defaults to **enabled** when the env var is unset (matches Swift, which +/// defaults `telemetryEnabled` to `true` in its config). +pub fn is_enabled() -> bool { + parse_env_bool(ENV_TELEMETRY_ENABLED).unwrap_or(true) +} + +/// Record a telemetry event. No-op when telemetry is disabled. +/// +/// Fire-and-forget: the HTTP POST runs on a `tokio::spawn`-ed background +/// task with a short timeout. The caller never blocks and never sees an +/// error from telemetry — only `tracing::debug!` on failure. +/// +/// `properties` is merged on top of the default envelope (version / OS / +/// arch / etc.). Pass `None` for the common case. +pub fn capture(event_name: &str, properties: Option) { + if !is_enabled() { + return; + } + spawn_capture(event_name.to_owned(), properties, /*bypass_opt_out*/ false); +} + +/// Record the one-time `cua_driver_install` event. Guarded by a +/// `.installation_recorded` marker file so it only fires once per install, +/// and **bypasses the opt-out check** — this is the only path that does so. +/// +/// Rationale: we count adoption (one ping per install) so the Rust port's +/// install metric is comparable to the Swift port's. Every subsequent +/// event respects the opt-out normally. +/// +/// Unlike [`capture`], this path posts **synchronously** so the marker +/// file is only written when the POST actually succeeds. A failed POST +/// (network, PostHog outage, timeout) leaves the marker absent so the +/// next launch retries — without this, a single bad network at install +/// time would silently drop the only adoption signal we have. +pub fn capture_install() { + capture_install_with_poster(post_to_posthog); +} + +/// Internal seam for [`capture_install`] so tests can inject a fake +/// HTTP poster. Returns immediately if the marker already exists or if +/// the HOME directory cannot be resolved. +fn capture_install_with_poster(post: F) +where + F: FnOnce(&serde_json::Value) -> Result, +{ + let home_dir = match telemetry_home_dir() { + Some(p) => p, + None => return, + }; + let marker_path = home_dir.join(INSTALLATION_RECORDED_FILE_NAME); + if marker_path.exists() { + // Already recorded — silent no-op (matches Swift). + return; + } + + // Build the payload directly (we still bypass the opt-out check here, + // see module docs + capture_install rationale) and POST synchronously. + let distinct_id = get_or_create_install_id(); + let payload = build_payload(event::INSTALL, None, &distinct_id); + let debug = debug_enabled(); + if debug { + eprintln!("[telemetry] sending event: {} (sync)", event::INSTALL); + } + + match post(&payload) { + Ok(status) if (200..300).contains(&status) => { + if debug { + eprintln!("[telemetry] {} status: {status}", event::INSTALL); + } + } + Ok(status) => { + // Non-2xx: treat as failure so the next launch retries. PostHog + // returns 200 on accepted capture; anything else (4xx auth / + // payload error, 5xx outage) is not a success. + debug_log(format_args!( + "{} non-success status {status}; marker not written, will retry", + event::INSTALL + )); + return; + } + Err(e) => { + debug_log(format_args!( + "{} failed: {e}; marker not written, will retry", + event::INSTALL + )); + return; + } + } + + // Only reached on HTTP 2xx — persist the marker so subsequent launches + // skip re-sending. IO errors here are non-fatal (next launch retries). + if let Err(e) = std::fs::create_dir_all(&home_dir) { + debug_log(format_args!("failed to create {}: {e}", home_dir.display())); + return; + } + if let Err(e) = std::fs::write(&marker_path, "1") { + debug_log(format_args!( + "failed to write install marker {}: {e}", + marker_path.display() + )); + } +} + +// ── Internals ──────────────────────────────────────────────────────────── + +/// Resolve `~/.cua-driver-rs`. Returns `None` only on the platform-impossible +/// case where neither `HOME` (Unix) nor `USERPROFILE` (Windows) is set. +fn telemetry_home_dir() -> Option { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE"))?; + Some(PathBuf::from(home).join(HOME_SUBDIRECTORY)) +} + +/// Read the per-install UUID, creating + persisting a fresh one if absent +/// or unreadable. Idempotent across calls in the same process via +/// `INSTALL_ID_CACHE`. +fn get_or_create_install_id() -> String { + static INSTALL_ID_CACHE: OnceLock = OnceLock::new(); + INSTALL_ID_CACHE + .get_or_init(load_or_create_install_id_uncached) + .clone() +} + +/// Disk-bound path. Read existing UUID if valid; otherwise generate and persist. +/// Separated from the `OnceLock` wrapper so tests can exercise the path logic +/// without contaminating process-global state. +fn load_or_create_install_id_uncached() -> String { + let Some(home_dir) = telemetry_home_dir() else { + // No HOME — generate an ephemeral UUID. Telemetry will still send, + // but the distinct_id won't be stable across runs. Acceptable + // fallback for non-interactive containers. + return uuid::Uuid::new_v4().to_string(); + }; + let id_path = home_dir.join(TELEMETRY_ID_FILE_NAME); + + // Try existing UUID first. + if let Ok(existing) = std::fs::read_to_string(&id_path) { + let trimmed = existing.trim(); + if !trimmed.is_empty() { + return trimmed.to_owned(); + } + } + + // Generate + persist a fresh UUID. Persistence failures are + // non-fatal — we still return the generated id, just without + // cross-run stability. + let new_id = uuid::Uuid::new_v4().to_string(); + if let Err(e) = std::fs::create_dir_all(&home_dir) { + debug_log(format_args!("failed to create {}: {e}", home_dir.display())); + return new_id; + } + if let Err(e) = std::fs::write(&id_path, &new_id) { + debug_log(format_args!( + "failed to persist install id {}: {e}", + id_path.display() + )); + } + new_id +} + +/// Build the PostHog event payload. Public-in-crate so unit tests can +/// assert on payload shape without an actual HTTP roundtrip. +pub(crate) fn build_payload( + event_name: &str, + properties: Option<&serde_json::Value>, + distinct_id: &str, +) -> serde_json::Value { + let version = env!("CARGO_PKG_VERSION"); + + let mut event_properties = serde_json::Map::new(); + // Caller-provided properties first, so default-envelope keys win on + // collision (matches Swift's `eventProperties[key] = value` order). + if let Some(serde_json::Value::Object(map)) = properties { + for (k, v) in map { + event_properties.insert(k.clone(), v.clone()); + } + } + event_properties.insert("cua_driver_version".into(), version.into()); + event_properties.insert("os".into(), os_name().into()); + event_properties.insert("os_version".into(), os_version().into()); + event_properties.insert("arch".into(), arch().into()); + event_properties.insert("is_ci".into(), is_ci().into()); + event_properties.insert("$lib".into(), "cua-driver-rs".into()); + event_properties.insert("$lib_version".into(), version.into()); + + serde_json::json!({ + "api_key": POSTHOG_API_KEY, + "event": event_name, + "distinct_id": distinct_id, + "properties": event_properties, + "timestamp": iso8601_now(), + }) +} + +/// Spawn the HTTP POST on a background tokio task. Caller returns immediately. +/// Requires a tokio runtime to be active in the current thread; falls back +/// to `std::thread::spawn` otherwise so CLI subcommands that don't build +/// a runtime (e.g. `list-tools`) still get telemetry coverage. +fn spawn_capture( + event_name: String, + properties: Option, + bypass_opt_out: bool, +) { + if !bypass_opt_out && !is_enabled() { + return; + } + let distinct_id = get_or_create_install_id(); + let payload = build_payload(&event_name, properties.as_ref(), &distinct_id); + let debug = debug_enabled(); + + let task = move || { + if debug { + eprintln!("[telemetry] sending event: {event_name}"); + } + match post_to_posthog(&payload) { + Ok(status) => { + if debug { + eprintln!("[telemetry] {event_name} status: {status}"); + } + } + Err(e) => { + if debug { + eprintln!("[telemetry] {event_name} failed: {e}"); + } else { + tracing::debug!(target: "cua_driver::telemetry", + "POST {event_name} failed: {e}"); + } + } + } + }; + + // Prefer tokio so the POST shares the runtime's reactor; fall back to + // a plain OS thread for sync entry-points (CLI subcommands like + // `list-tools` that don't construct a runtime). + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::spawn_blocking(task); + } else { + // Best-effort: detach a short-lived OS thread. ureq's POST is + // synchronous and self-contained, so this is safe and simple. + std::thread::Builder::new() + .name("cua-telemetry".into()) + .spawn(task) + .ok(); + } +} + +/// Actual ureq POST. Returns the HTTP status code on success. Anything else +/// (network error, 4xx/5xx, timeout) is propagated as `Err`. +fn post_to_posthog(payload: &serde_json::Value) -> Result { + let agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(POSTHOG_TIMEOUT_SECS))) + .build() + .new_agent(); + + match agent + .post(POSTHOG_CAPTURE_URL) + .header("Content-Type", "application/json") + .send_json(payload) + { + Ok(response) => Ok(response.status().as_u16()), + Err(e) => Err(e.to_string()), + } +} + +// ── Environment helpers ────────────────────────────────────────────────── + +fn parse_env_bool(var: &str) -> Option { + let raw = std::env::var(var).ok()?; + match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" | "no" | "off" => Some(false), + "1" | "true" | "yes" | "on" => Some(true), + _ => None, + } +} + +fn debug_enabled() -> bool { + parse_env_bool(ENV_TELEMETRY_DEBUG).unwrap_or(false) +} + +fn debug_log(args: std::fmt::Arguments<'_>) { + if debug_enabled() { + eprintln!("[telemetry] {args}"); + } else { + tracing::debug!(target: "cua_driver::telemetry", "{args}"); + } +} + +/// Stringly OS name — `"macos" | "windows" | "linux"` to match Swift's +/// `"macos"` value exactly on darwin. +fn os_name() -> &'static str { + std::env::consts::OS // already "macos"/"windows"/"linux" +} + +/// Reported OS version. Best-effort: returns whatever the OS exposes via +/// the canonical "release" file/registry. Swift uses +/// `ProcessInfo.operatingSystemVersionString` (e.g. "Version 14.5 (Build 23F79)"); +/// we approximate with a short string per platform. +fn os_version() -> String { + #[cfg(target_os = "macos")] + { + std::process::Command::new("sw_vers") + .arg("-productVersion") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_owned()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_owned()) + } + #[cfg(target_os = "linux")] + { + // `/etc/os-release` is the freedesktop standard; fall back to "unknown". + std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| { + s.lines() + .find_map(|l| l.strip_prefix("PRETTY_NAME=")) + .map(|v| v.trim_matches('"').to_owned()) + }) + .unwrap_or_else(|| "unknown".to_owned()) + } + #[cfg(target_os = "windows")] + { + // No std API; cmd /c ver is best-effort. + std::process::Command::new("cmd") + .args(["/c", "ver"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(String::from_utf8_lossy(&o.stdout).trim().to_owned()) + } else { + None + } + }) + .unwrap_or_else(|| "unknown".to_owned()) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + "unknown".to_owned() + } +} + +fn arch() -> &'static str { + // std::env::consts::ARCH returns "aarch64"/"x86_64"/etc. + // Map "aarch64" → "arm64" so the Rust label matches Swift's + // (`#if arch(arm64)` → `"arm64"`) and dashboards group correctly. + match std::env::consts::ARCH { + "aarch64" => "arm64", + other => other, + } +} + +/// True when any well-known CI env var is set. List mirrors Swift exactly. +fn is_ci() -> bool { + const CI_VARS: &[&str] = &[ + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "GITLAB_CI", + "JENKINS_URL", + "CIRCLECI", + ]; + CI_VARS.iter().any(|v| std::env::var_os(v).is_some()) +} + +/// ISO-8601 timestamp without bringing in a chrono dependency. PostHog +/// accepts `YYYY-MM-DDTHH:MM:SSZ` (UTC) directly. +fn iso8601_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Civil-date conversion: 1970-01-01 + secs. Algorithm cribbed from + // Howard Hinnant's civil_from_days; safe through year 9999. + let (year, month, day, hour, minute, second) = civil_from_unix(secs); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +fn civil_from_unix(unix_secs: u64) -> (i32, u32, u32, u32, u32, u32) { + let days = (unix_secs / 86_400) as i64; + let secs_of_day = (unix_secs % 86_400) as u32; + let hour = secs_of_day / 3600; + let minute = (secs_of_day % 3600) / 60; + let second = secs_of_day % 60; + + // Howard Hinnant's civil_from_days, shifted to 0000-03-01 era epoch. + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = (z - era * 146_097) as u32; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + let year = (y + if m <= 2 { 1 } else { 0 }) as i32; + (year, m, d, hour, minute, second) +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + /// All env-mutating tests serialise on this lock — `std::env::set_var` + /// is process-global, parallel tests would race. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn parse_env_bool_recognises_canonical_forms() { + let _g = ENV_LOCK.lock().unwrap(); + for (raw, expected) in [ + ("0", false), + ("false", false), + ("FALSE", false), + ("no", false), + ("off", false), + ("1", true), + ("true", true), + ("YES", true), + ("on", true), + ] { + unsafe { std::env::set_var("CUA_TELEMETRY_TEST_BOOL", raw); } + assert_eq!(parse_env_bool("CUA_TELEMETRY_TEST_BOOL"), Some(expected), + "raw={raw:?}"); + } + unsafe { std::env::set_var("CUA_TELEMETRY_TEST_BOOL", "maybe"); } + assert_eq!(parse_env_bool("CUA_TELEMETRY_TEST_BOOL"), None); + unsafe { std::env::remove_var("CUA_TELEMETRY_TEST_BOOL"); } + assert_eq!(parse_env_bool("CUA_TELEMETRY_TEST_BOOL"), None); + } + + #[test] + fn is_enabled_defaults_to_true_and_honors_env_opt_out() { + let _g = ENV_LOCK.lock().unwrap(); + unsafe { std::env::remove_var(ENV_TELEMETRY_ENABLED); } + assert!(is_enabled(), "default must be enabled (Swift parity)"); + + unsafe { std::env::set_var(ENV_TELEMETRY_ENABLED, "false"); } + assert!(!is_enabled(), "explicit false must disable"); + + unsafe { std::env::set_var(ENV_TELEMETRY_ENABLED, "0"); } + assert!(!is_enabled(), "0 must disable"); + + unsafe { std::env::set_var(ENV_TELEMETRY_ENABLED, "true"); } + assert!(is_enabled(), "explicit true must enable"); + + unsafe { std::env::remove_var(ENV_TELEMETRY_ENABLED); } + } + + #[test] + fn is_ci_detects_known_vars() { + let _g = ENV_LOCK.lock().unwrap(); + // Snapshot + clear all CI vars; restore at end. + let saved: Vec<(&str, Option)> = [ + "CI", "CONTINUOUS_INTEGRATION", "GITHUB_ACTIONS", + "GITLAB_CI", "JENKINS_URL", "CIRCLECI", + ].iter() + .map(|v| (*v, std::env::var(v).ok())) + .collect(); + for (v, _) in &saved { unsafe { std::env::remove_var(v); } } + + assert!(!is_ci(), "with no CI vars set, is_ci must be false"); + + unsafe { std::env::set_var("GITHUB_ACTIONS", "true"); } + assert!(is_ci(), "GITHUB_ACTIONS must trigger CI detection"); + unsafe { std::env::remove_var("GITHUB_ACTIONS"); } + + unsafe { std::env::set_var("CIRCLECI", "1"); } + assert!(is_ci(), "CIRCLECI must trigger CI detection"); + unsafe { std::env::remove_var("CIRCLECI"); } + + // Restore. + for (v, val) in saved { + match val { + Some(s) => unsafe { std::env::set_var(v, s); }, + None => unsafe { std::env::remove_var(v); }, + } + } + } + + #[test] + fn build_payload_contains_required_keys() { + let payload = build_payload( + "cua_driver_test", + Some(&serde_json::json!({"extra_key": "extra_val"})), + "test-distinct-id", + ); + // Top-level envelope. + assert_eq!(payload["api_key"], POSTHOG_API_KEY); + assert_eq!(payload["event"], "cua_driver_test"); + assert_eq!(payload["distinct_id"], "test-distinct-id"); + assert!(payload["timestamp"].is_string()); + + // Properties: default envelope + caller-provided. + let props = &payload["properties"]; + assert_eq!(props["cua_driver_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(props["$lib"], "cua-driver-rs"); + assert_eq!(props["$lib_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(props["os"], std::env::consts::OS); + assert_eq!(props["arch"], arch()); + assert!(props["is_ci"].is_boolean()); + assert!(props["os_version"].is_string()); + // Caller-provided property survives the merge. + assert_eq!(props["extra_key"], "extra_val"); + + // Privacy assertions: nothing that looks like a username, + // file path, or args should be in the payload. + let serialized = serde_json::to_string(&payload).unwrap(); + for forbidden in &["$user", "username", "home_dir", "cwd", "argv"] { + assert!(!serialized.contains(forbidden), + "payload must not contain {forbidden}: {serialized}"); + } + } + + #[test] + fn build_payload_default_envelope_wins_on_key_collision() { + // If a caller tries to override a default-envelope key, the + // default-envelope value must win (mirrors Swift's insertion order). + let payload = build_payload( + "cua_driver_test", + Some(&serde_json::json!({"os": "fake-os", "$lib": "fake-lib"})), + "id", + ); + assert_eq!(payload["properties"]["os"], std::env::consts::OS); + assert_eq!(payload["properties"]["$lib"], "cua-driver-rs"); + } + + #[test] + fn install_id_persists_across_reads() { + let _g = ENV_LOCK.lock().unwrap(); + // Redirect HOME to a temp dir so we don't touch the real + // `~/.cua-driver-rs`. + let tmp = std::env::temp_dir().join(format!( + "cua-driver-rs-telemetry-test-{}", uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let saved_home = std::env::var_os("HOME"); + let saved_userprofile = std::env::var_os("USERPROFILE"); + unsafe { std::env::set_var("HOME", &tmp); } + unsafe { std::env::set_var("USERPROFILE", &tmp); } + + let first = load_or_create_install_id_uncached(); + assert!(!first.is_empty()); + // UUID v4 is 36 chars (8-4-4-4-12). Sanity check. + assert_eq!(first.len(), 36); + + // Second call must read from disk and return the same UUID. + let second = load_or_create_install_id_uncached(); + assert_eq!(first, second, "install id must be stable across reads"); + + // File must exist on disk and contain the UUID. + let id_file = tmp.join(HOME_SUBDIRECTORY).join(TELEMETRY_ID_FILE_NAME); + let on_disk = std::fs::read_to_string(&id_file).unwrap(); + assert_eq!(on_disk.trim(), first); + + // Cleanup. + let _ = std::fs::remove_dir_all(&tmp); + match saved_home { + Some(s) => unsafe { std::env::set_var("HOME", s); }, + None => unsafe { std::env::remove_var("HOME"); }, + } + match saved_userprofile { + Some(s) => unsafe { std::env::set_var("USERPROFILE", s); }, + None => unsafe { std::env::remove_var("USERPROFILE"); }, + } + } + + #[test] + fn iso8601_now_is_well_formed() { + let s = iso8601_now(); + // YYYY-MM-DDTHH:MM:SSZ = 20 chars. + assert_eq!(s.len(), 20, "got {s:?}"); + assert!(s.ends_with('Z')); + assert_eq!(&s[4..5], "-"); + assert_eq!(&s[7..8], "-"); + assert_eq!(&s[10..11], "T"); + assert_eq!(&s[13..14], ":"); + assert_eq!(&s[16..17], ":"); + } + + #[test] + fn arch_maps_aarch64_to_arm64() { + // Smoke: just confirm we don't return "aarch64" on aarch64 hosts. + let a = arch(); + assert_ne!(a, "aarch64", "arm64 dashboards expect 'arm64' not 'aarch64'"); + } + + /// Redirect HOME/USERPROFILE to a fresh temp dir for the duration of + /// the closure, then restore. Used by `capture_install_*` tests so + /// they don't pollute the real `~/.cua-driver-rs`. + fn with_isolated_home(test: impl FnOnce(&std::path::Path) -> R) -> R { + let tmp = std::env::temp_dir().join(format!( + "cua-driver-rs-install-test-{}", uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let saved_home = std::env::var_os("HOME"); + let saved_userprofile = std::env::var_os("USERPROFILE"); + unsafe { std::env::set_var("HOME", &tmp); } + unsafe { std::env::set_var("USERPROFILE", &tmp); } + + let result = test(&tmp); + + let _ = std::fs::remove_dir_all(&tmp); + match saved_home { + Some(s) => unsafe { std::env::set_var("HOME", s); }, + None => unsafe { std::env::remove_var("HOME"); }, + } + match saved_userprofile { + Some(s) => unsafe { std::env::set_var("USERPROFILE", s); }, + None => unsafe { std::env::remove_var("USERPROFILE"); }, + } + result + } + + #[test] + fn capture_install_does_not_write_marker_when_post_fails() { + // The whole point of the sync install path: if the POST fails, + // the marker must NOT be written, so the next launch retries. + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + let marker = home.join(HOME_SUBDIRECTORY).join(INSTALLATION_RECORDED_FILE_NAME); + assert!(!marker.exists(), "precondition: marker must not exist"); + + capture_install_with_poster(|_payload| { + Err("simulated network failure".to_owned()) + }); + + assert!(!marker.exists(), + "marker must NOT be written when POST returns Err"); + }); + } + + #[test] + fn capture_install_does_not_write_marker_when_post_returns_non_2xx() { + // PostHog returning e.g. 500 must also be treated as failure. + let _g = ENV_LOCK.lock().unwrap(); + with_isolated_home(|home| { + let marker = home.join(HOME_SUBDIRECTORY).join(INSTALLATION_RECORDED_FILE_NAME); + + capture_install_with_poster(|_payload| Ok(500u16)); + assert!(!marker.exists(), + "marker must NOT be written on 5xx response"); + + capture_install_with_poster(|_payload| Ok(401u16)); + assert!(!marker.exists(), + "marker must NOT be written on 4xx response"); + }); + } + +} diff --git a/libs/cua-driver-rs/scripts/install.sh b/libs/cua-driver-rs/scripts/install.sh index 60877fec60..fa63eba4d4 100644 --- a/libs/cua-driver-rs/scripts/install.sh +++ b/libs/cua-driver-rs/scripts/install.sh @@ -214,6 +214,23 @@ else log "installed $BIN_LINK (version $VERSION)" fi +# --- Fire the one-shot install telemetry ping --------------------------- +# +# Anonymous adoption signal — sends `cua_driver_install` to PostHog +# exactly once per install (guarded by ~/.cua-driver-rs/.installation_recorded +# on the binary side). The Rust port keeps its install signal independent +# of the Swift `cua-driver` install (separate marker dir + separate env var) +# so users can opt out of one without affecting the other. +# +# Bypasses the CUA_DRIVER_RS_TELEMETRY_ENABLED check by design — see +# `telemetry::capture_install()` for the rationale (count adoption even +# when users opt out immediately after install). Every subsequent event +# from the binary respects the opt-out normally. +# +# Background + redirect so a slow / failed POST never blocks the install. +"$BIN_LINK" telemetry install-event >/dev/null 2>&1 & +disown 2>/dev/null || true + # Auto-extend PATH for users whose shell doesn't already include BIN_DIR. if [[ "$NO_MODIFY_PATH" != "1" ]] && [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then SHELL_RC=""