Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/goose-cli/src/session/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,8 @@ pub struct SessionSettings {
}

pub async fn build_session(session_config: SessionBuilderConfig) -> CliSession {
goose::posthog::set_session_context("cli", session_config.resume);

let config = Config::global();

let (saved_provider, saved_model_config) = if session_config.resume {
Expand Down
4 changes: 4 additions & 0 deletions crates/goose-server/src/routes/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ async fn start_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<StartAgentRequest>,
) -> Result<Json<Session>, ErrorResponse> {
goose::posthog::set_session_context("desktop", false);

let StartAgentRequest {
working_dir,
recipe,
Expand Down Expand Up @@ -197,6 +199,8 @@ async fn resume_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<ResumeAgentRequest>,
) -> Result<Json<Session>, ErrorResponse> {
goose::posthog::set_session_context("desktop", true);

let session = SessionManager::get_session(&payload.session_id, true)
.await
.map_err(|err| {
Expand Down
10 changes: 6 additions & 4 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,8 @@ impl Agent {
no_tools_called = false;
}
}
Err(ProviderError::ContextLengthExceeded(_error_msg)) => {
Err(ref provider_err @ ProviderError::ContextLengthExceeded(_)) => {
crate::posthog::emit_error(provider_err.telemetry_type());
yield AgentEvent::Message(
Message::assistant().with_system_notification(
SystemNotificationType::InlineMessage,
Expand Down Expand Up @@ -1255,11 +1256,12 @@ impl Agent {
}
}
}
Err(e) => {
error!("Error: {}", e);
Err(ref provider_err) => {
crate::posthog::emit_error(provider_err.telemetry_type());
error!("Error: {}", provider_err);
yield AgentEvent::Message(
Message::assistant().with_text(
format!("Ran into this error: {e}.\n\nPlease retry if you think this is a transient or recoverable error.")
format!("Ran into this error: {provider_err}.\n\nPlease retry if you think this is a transient or recoverable error.")
)
);
break;
Expand Down
6 changes: 3 additions & 3 deletions crates/goose/src/agents/tool_route_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ impl ToolRouteManager {

pub async fn record_tool_requests(&self, requests: &[ToolRequest]) {
let selector = self.router_tool_selector.lock().await.clone();
if let Some(selector) = selector {
for request in requests {
if let Ok(tool_call) = &request.tool_call {
for request in requests {
if let Ok(tool_call) = &request.tool_call {
if let Some(ref selector) = selector {
if let Err(e) = selector.record_tool_call(&tool_call.name).await {
error!("Failed to record tool call: {}", e);
}
Expand Down
233 changes: 228 additions & 5 deletions crates/goose/src/posthog.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
//! PostHog telemetry - fires once per session creation.

use crate::config::paths::Paths;
use crate::config::{get_enabled_extensions, Config};
use crate::session::SessionManager;
use chrono::{DateTime, Utc};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::fs;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use uuid::Uuid;

const POSTHOG_API_KEY: &str = "phc_RyX5CaY01VtZJCQyhSR5KFh6qimUy81YwxsEpotAftT";

Expand All @@ -25,7 +31,6 @@ static TELEMETRY_DISABLED_BY_ENV: Lazy<AtomicBool> = Lazy::new(|| {
///
/// Returns true otherwise (telemetry is opt-out, enabled by default)
pub fn is_telemetry_enabled() -> bool {
// Environment variable takes precedence
if TELEMETRY_DISABLED_BY_ENV.load(Ordering::Relaxed) {
return false;
}
Expand All @@ -36,24 +41,242 @@ pub fn is_telemetry_enabled() -> bool {
.unwrap_or(true)
}

// ============================================================================
// Installation Tracking
// ============================================================================

#[derive(Debug, Clone, Serialize, Deserialize)]
struct InstallationData {
installation_id: String,
first_seen: DateTime<Utc>,
session_count: u32,
}

impl Default for InstallationData {
fn default() -> Self {
Self {
installation_id: Uuid::new_v4().to_string(),
first_seen: Utc::now(),
session_count: 0,
}
}
}

fn installation_file_path() -> std::path::PathBuf {
Paths::state_dir().join("telemetry_installation.json")
}

fn load_or_create_installation() -> InstallationData {
let path = installation_file_path();

if let Ok(contents) = fs::read_to_string(&path) {
if let Ok(data) = serde_json::from_str::<InstallationData>(&contents) {
return data;
}
}

let data = InstallationData::default();
save_installation(&data);
data
}

fn save_installation(data: &InstallationData) {
let path = installation_file_path();

if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}

if let Ok(json) = serde_json::to_string_pretty(data) {
let _ = fs::write(path, json);
}
}

fn increment_session_count() -> InstallationData {
let mut data = load_or_create_installation();
data.session_count += 1;
save_installation(&data);
data
}

// ============================================================================
// Platform Info
// ============================================================================

fn get_platform_version() -> Option<String> {
#[cfg(target_os = "macos")]
{
std::process::Command::new("sw_vers")
.arg("-productVersion")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[cfg(target_os = "linux")]
{
fs::read_to_string("/etc/os-release")
.ok()
.and_then(|content| {
content
.lines()
.find(|line| line.starts_with("VERSION_ID="))
.map(|line| {
line.trim_start_matches("VERSION_ID=")
.trim_matches('"')
.to_string()
})
})
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "ver"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
None
}
}

fn detect_install_method() -> String {
let exe_path = std::env::current_exe().ok();

if let Some(path) = exe_path {
let path_str = path.to_string_lossy().to_lowercase();

if path_str.contains("homebrew") || path_str.contains("/opt/homebrew") {
return "homebrew".to_string();
}
if path_str.contains(".cargo") {
return "cargo".to_string();
}
if path_str.contains("applications") || path_str.contains(".app") {
return "desktop".to_string();
}
}

if std::env::var("GOOSE_DESKTOP").is_ok() {
return "desktop".to_string();
}

"binary".to_string()
}

// ============================================================================
// Session Context (set by CLI/Desktop at startup)
// ============================================================================

static SESSION_INTERFACE: Lazy<Mutex<Option<String>>> = Lazy::new(|| Mutex::new(None));
static SESSION_IS_RESUMED: AtomicBool = AtomicBool::new(false);

pub fn set_session_context(interface: &str, is_resumed: bool) {
if let Ok(mut iface) = SESSION_INTERFACE.lock() {
*iface = Some(interface.to_string());
}
SESSION_IS_RESUMED.store(is_resumed, Ordering::Relaxed);
}

fn get_session_interface() -> String {
SESSION_INTERFACE
.lock()
.ok()
.and_then(|i| i.clone())
.unwrap_or_else(|| "unknown".to_string())
}

fn get_session_is_resumed() -> bool {
SESSION_IS_RESUMED.load(Ordering::Relaxed)
}

// ============================================================================
// Telemetry Events
// ============================================================================

pub fn emit_session_started() {
if !is_telemetry_enabled() {
return;
}

tokio::spawn(async {
let _ = send_session_event().await;
let installation = increment_session_count();

tokio::spawn(async move {
let _ = send_session_event(&installation).await;
});
}

pub fn emit_error(error_type: &str) {
if !is_telemetry_enabled() {
return;
}

let installation = load_or_create_installation();
let error_type = error_type.to_string();

tokio::spawn(async move {
let _ = send_error_event(&installation, &error_type).await;
});
}

async fn send_session_event() -> Result<(), String> {
async fn send_error_event(installation: &InstallationData, error_type: &str) -> Result<(), String> {
let client = posthog_rs::client(POSTHOG_API_KEY).await;
let mut event = posthog_rs::Event::new("error", &installation.installation_id);

event.insert_prop("error_type", error_type).ok();
event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();
event.insert_prop("interface", get_session_interface()).ok();
event.insert_prop("os", std::env::consts::OS).ok();
event.insert_prop("arch", std::env::consts::ARCH).ok();

if let Some(platform_version) = get_platform_version() {
event.insert_prop("platform_version", platform_version).ok();
}

let config = Config::global();
if let Ok(provider) = config.get_param::<String>("GOOSE_PROVIDER") {
event.insert_prop("provider", provider).ok();
}
if let Ok(model) = config.get_param::<String>("GOOSE_MODEL") {
event.insert_prop("model", model).ok();
}

client.capture(event).await.map_err(|e| format!("{:?}", e))
}

async fn send_session_event(installation: &InstallationData) -> Result<(), String> {
let client = posthog_rs::client(POSTHOG_API_KEY).await;
let mut event = posthog_rs::Event::new("session_started", "goose_user");
let mut event = posthog_rs::Event::new("session_started", &installation.installation_id);

event.insert_prop("os", std::env::consts::OS).ok();
event.insert_prop("arch", std::env::consts::ARCH).ok();
event.insert_prop("version", env!("CARGO_PKG_VERSION")).ok();

if let Some(platform_version) = get_platform_version() {
event.insert_prop("platform_version", platform_version).ok();
}

event
.insert_prop("install_method", detect_install_method())
.ok();

event.insert_prop("interface", get_session_interface()).ok();

event
.insert_prop("is_resumed", get_session_is_resumed())
.ok();

event
.insert_prop("session_number", installation.session_count)
.ok();
let days_since_install = (Utc::now() - installation.first_seen).num_days();
event
.insert_prop("days_since_install", days_since_install)
.ok();

let config = Config::global();
if let Ok(provider) = config.get_param::<String>("GOOSE_PROVIDER") {
event.insert_prop("provider", provider).ok();
Expand Down
15 changes: 15 additions & 0 deletions crates/goose/src/providers/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ pub enum ProviderError {
NotImplemented(String),
}

impl ProviderError {
pub fn telemetry_type(&self) -> &'static str {
match self {
ProviderError::Authentication(_) => "auth",
ProviderError::ContextLengthExceeded(_) => "context_length",
ProviderError::RateLimitExceeded { .. } => "rate_limit",
ProviderError::ServerError(_) => "server",
ProviderError::RequestFailed(_) => "request",
ProviderError::ExecutionError(_) => "execution",
ProviderError::UsageError(_) => "usage",
ProviderError::NotImplemented(_) => "not_implemented",
}
}
}

impl From<anyhow::Error> for ProviderError {
fn from(error: anyhow::Error) -> Self {
if let Some(reqwest_err) = error.downcast_ref::<reqwest::Error>() {
Expand Down
13 changes: 7 additions & 6 deletions ui/desktop/src/components/TelemetryOptOutModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,16 @@ export default function TelemetryOptOutModal(props: TelemetryOptOutModalProps) {
<div className="text-text-muted text-xs space-y-1">
<p className="font-medium text-text-default">What we collect:</p>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>Operating system and architecture</li>
<li>goose version</li>
<li>Operating system, version, and architecture</li>
<li>goose version and install method</li>
<li>Provider and model used</li>
<li>Number of extensions enabled</li>
<li>Session count and token usage (aggregated)</li>
<li>Extensions and tool usage counts (names only)</li>
<li>Session metrics (duration, interaction count, token usage)</li>
<li>Error types (e.g., "rate_limit", "auth" - no details)</li>
</ul>
<p className="mt-3 text-text-muted">
We never collect your conversations, code, or any personal data. You can change this
setting anytime in Settings → App.
We never collect your conversations, code, tool arguments, error messages, or any
personal data. You can change this setting anytime in Settings → App.
</p>
</div>
</div>
Expand Down