chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "jcode-telemetry-core"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
name = "jcode_telemetry_core"
path = "src/lib.rs"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
jcode-build-meta = { path = "../jcode-build-meta" }
jcode-core = { path = "../jcode-core" }
jcode-logging = { path = "../jcode-logging" }
jcode-provider-core = { path = "../jcode-provider-core" }
jcode-selfdev-types = { path = "../jcode-selfdev-types" }
jcode-storage = { path = "../jcode-storage" }
jcode-usage-types = { path = "../jcode-usage-types" }
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "charset", "http2", "system-proxy", "rustls-tls", "rustls-tls-native-roots"] }
serde_json = "1"
walkdir = "2"
tokio = { version = "1", features = ["rt"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tempfile = "3"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,331 @@
use super::*;
pub(super) fn emit_lifecycle_event(
event_name: &'static str,
provider_end: &str,
model_end: &str,
reason: SessionEndReason,
clear_state: bool,
) {
if !is_enabled() {
return;
}
let id = match get_or_create_id() {
Some(id) => id,
None => return,
};
let state = {
let mut guard = match SESSION_STATE.lock() {
Ok(g) => g,
Err(_) => return,
};
let now = Instant::now();
if let Some(active) = guard.as_mut() {
finalize_current_turn(&id, active, now, reason.as_str(), DeliveryMode::Background);
observe_session_concurrency(active);
}
let state = match guard.as_ref() {
Some(s) => SessionTelemetry {
session_id: s.session_id.clone(),
started_at: s.started_at,
started_at_utc: s.started_at_utc,
provider_start: s.provider_start.clone(),
model_start: s.model_start.clone(),
parent_session_id: s.parent_session_id.clone(),
turns: s.turns,
had_user_prompt: s.had_user_prompt,
had_assistant_response: s.had_assistant_response,
assistant_responses: s.assistant_responses,
first_assistant_response_ms: s.first_assistant_response_ms,
first_tool_call_ms: s.first_tool_call_ms,
first_tool_success_ms: s.first_tool_success_ms,
first_file_edit_ms: s.first_file_edit_ms,
first_test_pass_ms: s.first_test_pass_ms,
tool_calls: s.tool_calls,
tool_failures: s.tool_failures,
executed_tool_calls: s.executed_tool_calls,
executed_tool_successes: s.executed_tool_successes,
executed_tool_failures: s.executed_tool_failures,
tool_latency_total_ms: s.tool_latency_total_ms,
tool_latency_max_ms: s.tool_latency_max_ms,
file_write_calls: s.file_write_calls,
tests_run: s.tests_run,
tests_passed: s.tests_passed,
input_tokens: s.input_tokens,
output_tokens: s.output_tokens,
cache_read_input_tokens: s.cache_read_input_tokens,
cache_creation_input_tokens: s.cache_creation_input_tokens,
total_tokens: s.total_tokens,
feature_memory_used: s.feature_memory_used,
feature_swarm_used: s.feature_swarm_used,
feature_web_used: s.feature_web_used,
feature_email_used: s.feature_email_used,
feature_mcp_used: s.feature_mcp_used,
feature_side_panel_used: s.feature_side_panel_used,
feature_goal_used: s.feature_goal_used,
feature_selfdev_used: s.feature_selfdev_used,
feature_background_used: s.feature_background_used,
feature_subagent_used: s.feature_subagent_used,
unique_mcp_servers: s.unique_mcp_servers.clone(),
transport_https: s.transport_https,
transport_persistent_ws_fresh: s.transport_persistent_ws_fresh,
transport_persistent_ws_reuse: s.transport_persistent_ws_reuse,
transport_cli_subprocess: s.transport_cli_subprocess,
transport_native_http2: s.transport_native_http2,
transport_other: s.transport_other,
agent_active_ms_total: s.agent_active_ms_total,
agent_model_ms_total: s.agent_model_ms_total,
agent_tool_ms_total: s.agent_tool_ms_total,
session_idle_ms_total: s.session_idle_ms_total,
agent_blocked_ms_total: s.agent_blocked_ms_total,
time_to_first_agent_action_ms: s.time_to_first_agent_action_ms,
time_to_first_useful_action_ms: s.time_to_first_useful_action_ms,
spawned_agent_count: s.spawned_agent_count,
background_task_count: s.background_task_count,
background_task_completed_count: s.background_task_completed_count,
subagent_task_count: s.subagent_task_count,
subagent_success_count: s.subagent_success_count,
swarm_task_count: s.swarm_task_count,
swarm_success_count: s.swarm_success_count,
user_cancelled_count: s.user_cancelled_count,
tool_cat_read_search: s.tool_cat_read_search,
tool_cat_write: s.tool_cat_write,
tool_cat_shell: s.tool_cat_shell,
tool_cat_web: s.tool_cat_web,
tool_cat_memory: s.tool_cat_memory,
tool_cat_subagent: s.tool_cat_subagent,
tool_cat_swarm: s.tool_cat_swarm,
tool_cat_email: s.tool_cat_email,
tool_cat_side_panel: s.tool_cat_side_panel,
tool_cat_goal: s.tool_cat_goal,
tool_cat_mcp: s.tool_cat_mcp,
tool_cat_other: s.tool_cat_other,
command_login_used: s.command_login_used,
command_model_used: s.command_model_used,
command_usage_used: s.command_usage_used,
command_resume_used: s.command_resume_used,
command_memory_used: s.command_memory_used,
command_swarm_used: s.command_swarm_used,
command_goal_used: s.command_goal_used,
command_selfdev_used: s.command_selfdev_used,
command_feedback_used: s.command_feedback_used,
command_other_used: s.command_other_used,
previous_session_gap_secs: s.previous_session_gap_secs,
sessions_started_24h: s.sessions_started_24h,
sessions_started_7d: s.sessions_started_7d,
active_sessions_at_start: s.active_sessions_at_start,
other_active_sessions_at_start: s.other_active_sessions_at_start,
max_concurrent_sessions: s.max_concurrent_sessions,
current_turn: None,
resumed_session: s.resumed_session,
start_event_sent: s.start_event_sent,
error_provider_timeout: s.error_provider_timeout,
error_auth_failed: s.error_auth_failed,
error_tool_error: s.error_tool_error,
error_mcp_error: s.error_mcp_error,
error_rate_limited: s.error_rate_limited,
provider_switches: s.provider_switches,
model_switches: s.model_switches,
},
None => return,
};
if clear_state {
*guard = None;
}
state
};
let errors = current_error_counts(&state);
if !session_has_meaningful_activity(&state, &errors) {
return;
}
if !state.start_event_sent {
let _ = emit_session_start_for_state(
id.clone(),
&state,
DeliveryMode::Blocking(BLOCKING_LIFECYCLE_TIMEOUT),
);
}
let duration = state.started_at.elapsed();
let session_success = state.had_assistant_response
|| state.executed_tool_successes > 0
|| state.tests_passed > 0
|| state.file_write_calls > 0;
let abandoned_before_response = state.had_user_prompt
&& !state.had_assistant_response
&& state.executed_tool_successes == 0;
let workflow_coding_used = state.file_write_calls > 0 || state.tool_cat_write > 0;
let workflow_research_used = state.feature_web_used || state.tool_cat_web > 0;
let workflow_tests_used = state.tests_run > 0 || state.tests_passed > 0;
let workflow_background_used = state.feature_background_used;
let workflow_subagent_used = state.feature_subagent_used || state.tool_cat_subagent > 0;
let workflow_swarm_used = state.feature_swarm_used || state.tool_cat_swarm > 0;
let workflow_chat_only = state.had_user_prompt
&& !workflow_coding_used
&& !workflow_research_used
&& !workflow_tests_used
&& !workflow_background_used
&& !workflow_subagent_used
&& !workflow_swarm_used;
let project_profile = detect_project_profile();
let (active_days_7d, active_days_30d) = update_active_days(&id);
let days_since_install = days_since_install(&id);
let ended_at_utc = Utc::now();
let (schema_version, build_channel, git_checkout, ci, from_cargo) = telemetry_envelope();
let session_stop_reason = infer_session_stop_reason(
event_name,
reason,
&state,
&errors,
duration.as_secs(),
session_success,
abandoned_before_response,
workflow_coding_used,
);
let agent_role = infer_agent_role(&state);
let time_to_first_agent_action_ms = time_to_first_agent_action_ms(&state);
let time_to_first_useful_action_ms = time_to_first_useful_action_ms(&state);
let event = SessionLifecycleEvent {
event_id: new_event_id(),
id,
session_id: state.session_id.clone(),
event: event_name,
version: version(),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
provider_start: state.provider_start,
provider_end: sanitize_telemetry_label(provider_end),
model_start: state.model_start,
model_end: sanitize_telemetry_label(model_end),
provider_switches: state.provider_switches,
model_switches: state.model_switches,
duration_mins: duration.as_secs() / 60,
duration_secs: duration.as_secs(),
turns: state.turns,
had_user_prompt: state.had_user_prompt,
had_assistant_response: state.had_assistant_response,
assistant_responses: state.assistant_responses,
first_assistant_response_ms: state.first_assistant_response_ms,
first_tool_call_ms: state.first_tool_call_ms,
first_tool_success_ms: state.first_tool_success_ms,
first_file_edit_ms: state.first_file_edit_ms,
first_test_pass_ms: state.first_test_pass_ms,
tool_calls: state.tool_calls,
tool_failures: state.tool_failures,
executed_tool_calls: state.executed_tool_calls,
executed_tool_successes: state.executed_tool_successes,
executed_tool_failures: state.executed_tool_failures,
tool_latency_total_ms: state.tool_latency_total_ms,
tool_latency_max_ms: state.tool_latency_max_ms,
file_write_calls: state.file_write_calls,
tests_run: state.tests_run,
tests_passed: state.tests_passed,
input_tokens: state.input_tokens,
output_tokens: state.output_tokens,
cache_read_input_tokens: state.cache_read_input_tokens,
cache_creation_input_tokens: state.cache_creation_input_tokens,
total_tokens: state.total_tokens,
feature_memory_used: state.feature_memory_used,
feature_swarm_used: state.feature_swarm_used,
feature_web_used: state.feature_web_used,
feature_email_used: state.feature_email_used,
feature_mcp_used: state.feature_mcp_used,
feature_side_panel_used: state.feature_side_panel_used,
feature_goal_used: state.feature_goal_used,
feature_selfdev_used: state.feature_selfdev_used,
feature_background_used: state.feature_background_used,
feature_subagent_used: state.feature_subagent_used,
unique_mcp_servers: state.unique_mcp_servers.len() as u32,
session_success,
abandoned_before_response,
session_stop_reason,
agent_role,
parent_session_id: state.parent_session_id.clone(),
agent_active_ms_total: state.agent_active_ms_total,
agent_model_ms_total: state.agent_model_ms_total,
agent_tool_ms_total: state.agent_tool_ms_total,
session_idle_ms_total: state.session_idle_ms_total,
agent_blocked_ms_total: state.agent_blocked_ms_total,
time_to_first_agent_action_ms,
time_to_first_useful_action_ms,
spawned_agent_count: state.spawned_agent_count,
background_task_count: state.background_task_count,
background_task_completed_count: state.background_task_completed_count,
subagent_task_count: state.subagent_task_count,
subagent_success_count: state.subagent_success_count,
swarm_task_count: state.swarm_task_count,
swarm_success_count: state.swarm_success_count,
user_cancelled_count: state.user_cancelled_count,
transport_https: state.transport_https,
transport_persistent_ws_fresh: state.transport_persistent_ws_fresh,
transport_persistent_ws_reuse: state.transport_persistent_ws_reuse,
transport_cli_subprocess: state.transport_cli_subprocess,
transport_native_http2: state.transport_native_http2,
transport_other: state.transport_other,
tool_cat_read_search: state.tool_cat_read_search,
tool_cat_write: state.tool_cat_write,
tool_cat_shell: state.tool_cat_shell,
tool_cat_web: state.tool_cat_web,
tool_cat_memory: state.tool_cat_memory,
tool_cat_subagent: state.tool_cat_subagent,
tool_cat_swarm: state.tool_cat_swarm,
tool_cat_email: state.tool_cat_email,
tool_cat_side_panel: state.tool_cat_side_panel,
tool_cat_goal: state.tool_cat_goal,
tool_cat_mcp: state.tool_cat_mcp,
tool_cat_other: state.tool_cat_other,
command_login_used: state.command_login_used,
command_model_used: state.command_model_used,
command_usage_used: state.command_usage_used,
command_resume_used: state.command_resume_used,
command_memory_used: state.command_memory_used,
command_swarm_used: state.command_swarm_used,
command_goal_used: state.command_goal_used,
command_selfdev_used: state.command_selfdev_used,
command_feedback_used: state.command_feedback_used,
command_other_used: state.command_other_used,
workflow_chat_only,
workflow_coding_used,
workflow_research_used,
workflow_tests_used,
workflow_background_used,
workflow_subagent_used,
workflow_swarm_used,
project_repo_present: project_profile.repo_present,
project_lang_rust: project_profile.lang_rust,
project_lang_js_ts: project_profile.lang_js_ts,
project_lang_python: project_profile.lang_python,
project_lang_go: project_profile.lang_go,
project_lang_markdown: project_profile.lang_markdown,
project_lang_mixed: project_profile.mixed(),
days_since_install,
active_days_7d,
active_days_30d,
session_start_hour_utc: utc_hour(state.started_at_utc),
session_start_weekday_utc: utc_weekday(state.started_at_utc),
session_end_hour_utc: utc_hour(ended_at_utc),
session_end_weekday_utc: utc_weekday(ended_at_utc),
previous_session_gap_secs: state.previous_session_gap_secs,
sessions_started_24h: state.sessions_started_24h,
sessions_started_7d: state.sessions_started_7d,
active_sessions_at_start: state.active_sessions_at_start,
other_active_sessions_at_start: state.other_active_sessions_at_start,
max_concurrent_sessions: state.max_concurrent_sessions,
multi_sessioned: state.max_concurrent_sessions > 1
|| state.other_active_sessions_at_start > 0,
resumed_session: state.resumed_session,
end_reason: reason.as_str(),
schema_version,
build_channel,
is_git_checkout: git_checkout,
is_ci: ci,
ran_from_cargo: from_cargo,
errors,
};
if let Ok(payload) = serde_json::to_value(&event) {
let _ = send_payload(payload, DeliveryMode::Blocking(BLOCKING_LIFECYCLE_TIMEOUT));
}
unregister_active_session(&state.session_id);
if session_success {
emit_onboarding_step_once("first_session_success", None, None);
}
}
@@ -0,0 +1,394 @@
use super::{SESSION_STATE, sanitize_telemetry_label};
use chrono::{DateTime, Datelike, Timelike, Utc};
use jcode_storage as storage;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
pub(super) fn telemetry_id_path() -> Option<PathBuf> {
storage::jcode_dir().ok().map(|d| d.join("telemetry_id"))
}
pub(super) fn install_recorded_path() -> Option<PathBuf> {
storage::jcode_dir()
.ok()
.map(|d| d.join("telemetry_install_sent"))
}
pub(super) fn version_recorded_path() -> Option<PathBuf> {
storage::jcode_dir()
.ok()
.map(|d| d.join("telemetry_version_sent"))
}
pub(super) fn telemetry_state_path(name: &str) -> Option<PathBuf> {
storage::jcode_dir().ok().map(|d| d.join(name))
}
pub(super) fn milestone_recorded_path(id: &str, key: &str) -> Option<PathBuf> {
telemetry_state_path(&format!(
"telemetry_milestone_{}_{}",
sanitize_telemetry_label(key),
id
))
}
pub(super) fn onboarding_step_milestone_key(
step: &str,
auth_provider: Option<&str>,
auth_method: Option<&str>,
) -> String {
fn normalize_part(value: &str) -> String {
let sanitized = sanitize_telemetry_label(value);
let collapsed = sanitized
.split_whitespace()
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("_");
collapsed.to_ascii_lowercase()
}
let mut parts = vec![normalize_part(step)];
if let Some(provider) = auth_provider {
let provider = normalize_part(provider);
if !provider.is_empty() {
parts.push(provider);
}
}
if let Some(method) = auth_method {
let method = normalize_part(method);
if !method.is_empty() {
parts.push(method);
}
}
parts.join("_")
}
pub(super) fn active_days_path(id: &str) -> Option<PathBuf> {
telemetry_state_path(&format!("telemetry_active_days_{}.txt", id))
}
pub(super) fn session_starts_path(id: &str) -> Option<PathBuf> {
telemetry_state_path(&format!("telemetry_session_starts_{}.txt", id))
}
pub(super) fn active_sessions_dir() -> Option<PathBuf> {
telemetry_state_path("telemetry_active_sessions")
}
pub(super) fn active_session_file(session_id: &str) -> Option<PathBuf> {
active_sessions_dir().map(|dir| dir.join(format!("{}.active", session_id)))
}
pub(super) fn write_private_file(path: &PathBuf, value: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, value);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
pub(super) fn utc_hour(timestamp: DateTime<Utc>) -> u32 {
timestamp.hour()
}
pub(super) fn utc_weekday(timestamp: DateTime<Utc>) -> u32 {
timestamp.weekday().num_days_from_monday()
}
pub(super) fn write_private_dir_file(path: &PathBuf, value: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
write_private_file(path, value);
}
pub(super) fn read_epoch_lines(path: &PathBuf) -> Vec<i64> {
std::fs::read_to_string(path)
.ok()
.into_iter()
.flat_map(|text| {
text.lines()
.map(str::trim)
.map(str::to_string)
.collect::<Vec<_>>()
})
.filter_map(|line| line.parse::<i64>().ok())
.collect()
}
pub(super) fn update_session_start_history(
id: &str,
started_at_utc: DateTime<Utc>,
) -> (Option<u64>, u32, u32) {
let Some(path) = session_starts_path(id) else {
return (None, 0, 0);
};
let now = started_at_utc.timestamp();
let cutoff_30d = now - 30 * 24 * 60 * 60;
let mut starts = read_epoch_lines(&path)
.into_iter()
.filter(|value| *value >= cutoff_30d)
.collect::<Vec<_>>();
starts.sort_unstable();
let previous = starts.last().copied();
starts.push(now);
let rendered = starts
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join("\n");
write_private_dir_file(&path, &rendered);
let sessions_started_24h = starts
.iter()
.filter(|value| now.saturating_sub(**value) < 24 * 60 * 60)
.count()
.min(u32::MAX as usize) as u32;
let sessions_started_7d = starts
.iter()
.filter(|value| now.saturating_sub(**value) < 7 * 24 * 60 * 60)
.count()
.min(u32::MAX as usize) as u32;
let previous_session_gap_secs = previous
.and_then(|value| now.checked_sub(value))
.map(|value| value.min(u64::MAX as i64) as u64);
(
previous_session_gap_secs,
sessions_started_24h,
sessions_started_7d,
)
}
pub(super) fn prune_active_session_files(dir: &PathBuf) -> u32 {
let _ = std::fs::create_dir_all(dir);
let now = SystemTime::now();
let max_age = Duration::from_secs(24 * 60 * 60);
let mut count = 0u32;
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return 0,
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
let fresh = entry
.metadata()
.ok()
.and_then(|meta| meta.modified().ok())
.and_then(|modified| now.duration_since(modified).ok())
.map(|age| age <= max_age)
.unwrap_or(false);
if fresh {
count = count.saturating_add(1);
} else {
let _ = std::fs::remove_file(path);
}
}
count
}
pub(super) fn register_active_session(session_id: &str) -> (u32, u32) {
let Some(dir) = active_sessions_dir() else {
return (0, 0);
};
let existing = prune_active_session_files(&dir);
if let Some(path) = active_session_file(session_id) {
write_private_dir_file(&path, "1");
}
(existing.saturating_add(1), existing)
}
pub(super) fn observe_active_sessions() -> u32 {
active_sessions_dir()
.map(|dir| prune_active_session_files(&dir))
.unwrap_or(0)
}
pub(super) fn unregister_active_session(session_id: &str) {
if let Some(path) = active_session_file(session_id) {
let _ = std::fs::remove_file(path);
}
}
pub(super) fn get_or_create_id() -> Option<String> {
let path = telemetry_id_path()?;
if let Ok(id) = std::fs::read_to_string(&path) {
let id = id.trim().to_string();
if !id.is_empty() {
return Some(id);
}
}
let id = uuid::Uuid::new_v4().to_string();
write_private_file(&path, &id);
Some(id)
}
pub(super) fn is_first_run() -> bool {
telemetry_id_path().map(|p| !p.exists()).unwrap_or(false)
}
pub(super) fn version() -> String {
jcode_build_meta::PKG_VERSION.to_string()
}
pub(super) fn install_recorded_for_id(id: &str) -> bool {
install_recorded_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.map(|stored| stored.trim() == id)
.unwrap_or(false)
}
pub(super) fn mark_install_recorded(id: &str) {
if let Some(path) = install_recorded_path() {
write_private_file(&path, id);
}
}
pub(super) fn previously_recorded_version() -> Option<String> {
version_recorded_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(super) fn mark_current_version_recorded() {
if let Some(path) = version_recorded_path() {
write_private_file(&path, &version());
}
}
pub(super) fn new_event_id() -> String {
uuid::Uuid::new_v4().to_string()
}
pub(super) fn is_jcode_repo_dir(dir: &Path) -> bool {
let cargo_toml = dir.join("Cargo.toml");
if !cargo_toml.exists() || !dir.join(".git").exists() {
return false;
}
std::fs::read_to_string(cargo_toml)
.map(|content| content.contains("name = \"jcode\""))
.unwrap_or(false)
}
fn find_jcode_repo_in_ancestors(start: &Path) -> Option<PathBuf> {
start
.ancestors()
.find(|dir| is_jcode_repo_dir(dir))
.map(Path::to_path_buf)
}
fn telemetry_jcode_repo_dir() -> Option<PathBuf> {
if let Ok(path) = std::env::var("JCODE_REPO_DIR") {
let path = PathBuf::from(path);
if is_jcode_repo_dir(&path) {
return Some(path);
}
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if let Some(repo) = find_jcode_repo_in_ancestors(&manifest_dir) {
return Some(repo);
}
if let Ok(exe) = std::env::current_exe()
&& let Some(repo) = exe
.parent()
.and_then(Path::parent)
.and_then(Path::parent)
.filter(|dir| is_jcode_repo_dir(dir))
{
return Some(repo.to_path_buf());
}
std::env::current_dir()
.ok()
.and_then(|cwd| find_jcode_repo_in_ancestors(&cwd))
}
pub(super) fn build_channel() -> String {
if std::env::var(jcode_selfdev_types::CLIENT_SELFDEV_ENV).is_ok() {
return "selfdev".to_string();
}
if let Ok(exe) = std::env::current_exe() {
let path = exe.to_string_lossy();
if path.contains("/target/debug/") || path.contains("\\target\\debug\\") {
return "debug".to_string();
}
if path.contains("/target/release/") || path.contains("\\target\\release\\") {
return "local_build".to_string();
}
}
if telemetry_jcode_repo_dir().is_some() {
return "git_checkout".to_string();
}
"release".to_string()
}
pub(super) fn is_git_checkout() -> bool {
telemetry_jcode_repo_dir().is_some()
}
pub(super) fn is_ci() -> bool {
[
"CI",
"GITHUB_ACTIONS",
"BUILDKITE",
"JENKINS_URL",
"GITLAB_CI",
"CIRCLECI",
]
.iter()
.any(|key| std::env::var(key).is_ok())
}
pub(super) fn ran_from_cargo() -> bool {
std::env::var("CARGO").is_ok() || std::env::var("CARGO_MANIFEST_DIR").is_ok()
}
pub(super) fn install_anchor_time(id: &str) -> Option<SystemTime> {
install_recorded_path()
.filter(|path| install_recorded_for_id(id) && path.exists())
.and_then(|path| std::fs::metadata(path).ok())
.and_then(|meta| meta.modified().ok())
.or_else(|| {
telemetry_id_path()
.and_then(|path| std::fs::metadata(path).ok())
.and_then(|meta| meta.modified().ok())
})
}
pub(super) fn elapsed_since_install_ms(id: &str) -> Option<u64> {
let anchor = install_anchor_time(id)?;
let elapsed = SystemTime::now().duration_since(anchor).ok()?;
Some(elapsed.as_millis().min(u128::from(u64::MAX)) as u64)
}
pub(super) fn days_since_install(id: &str) -> Option<u32> {
let anchor = install_anchor_time(id)?;
let elapsed = SystemTime::now().duration_since(anchor).ok()?;
Some((elapsed.as_secs() / 86_400).min(u64::from(u32::MAX)) as u32)
}
pub(super) fn milestone_recorded(id: &str, step: &str) -> bool {
milestone_recorded_path(id, step)
.map(|path| path.exists())
.unwrap_or(false)
}
pub(super) fn mark_milestone_recorded(id: &str, step: &str) {
if let Some(path) = milestone_recorded_path(id, step) {
write_private_file(&path, "1");
}
}
pub(super) fn current_session_id() -> Option<String> {
SESSION_STATE
.lock()
.map(|state| state.as_ref().map(|s| s.session_id.clone()))
.ok()
.flatten()
}
+524
View File
@@ -0,0 +1,524 @@
use super::*;
use std::sync::{Mutex, OnceLock};
// All of these tests mutate process-global state: the env-var opt-out tests
// flip `JCODE_NO_TELEMETRY` / `DO_NOT_TRACK`, while the session tests drive the
// global `SESSION_STATE`. They must be serialized against *each other* with a
// single shared lock. Using two separate locks previously let an env test
// disable telemetry (`is_enabled() == false`) while a session test was calling
// `begin_session_with_mode`, which then returned early and left `SESSION_STATE`
// as `None`; the session test's `expect(...)` panicked while holding the
// `SESSION_STATE` lock and poisoned it, cascading into `PoisonError` failures
// in every other session test.
fn global_test_lock() -> std::sync::MutexGuard<'static, ()> {
static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
TEST_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
#[test]
fn permanent_telemetry_statuses_trip_the_process_breaker() {
assert!(telemetry_status_is_permanent(400));
assert!(telemetry_status_is_permanent(401));
assert!(telemetry_status_is_permanent(404));
assert!(!telemetry_status_is_permanent(408));
assert!(!telemetry_status_is_permanent(425));
assert!(!telemetry_status_is_permanent(429));
assert!(!telemetry_status_is_permanent(500));
}
fn lock_test_env() -> std::sync::MutexGuard<'static, ()> {
global_test_lock()
}
fn lock_telemetry_test_state() -> std::sync::MutexGuard<'static, ()> {
global_test_lock()
}
#[test]
fn test_opt_out_env_var() {
let _guard = lock_test_env();
jcode_core::env::set_var("JCODE_NO_TELEMETRY", "1");
assert!(!is_enabled());
jcode_core::env::remove_var("JCODE_NO_TELEMETRY");
}
#[test]
fn test_do_not_track() {
let _guard = lock_test_env();
jcode_core::env::set_var("DO_NOT_TRACK", "1");
assert!(!is_enabled());
jcode_core::env::remove_var("DO_NOT_TRACK");
}
#[test]
fn test_is_ci_detects_ci_env() {
let _guard = lock_test_env();
// Clear any inherited CI markers so the baseline is deterministic.
for key in [
"CI",
"GITHUB_ACTIONS",
"BUILDKITE",
"JENKINS_URL",
"GITLAB_CI",
"CIRCLECI",
] {
jcode_core::env::remove_var(key);
}
assert!(
!is_ci(),
"expected non-CI baseline after clearing CI markers"
);
jcode_core::env::set_var("CI", "true");
assert!(
is_ci(),
"CI env var should mark the run as CI (gates install skip)"
);
jcode_core::env::remove_var("CI");
assert!(!is_ci());
}
#[test]
fn test_error_counters() {
let _guard = lock_telemetry_test_state();
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
begin_session_with_mode("openai", "gpt-5.4", None, false);
record_error(ErrorCategory::ProviderTimeout);
record_error(ErrorCategory::ProviderTimeout);
record_error(ErrorCategory::ToolError);
{
let guard = SESSION_STATE.lock().unwrap();
let state = guard.as_ref().expect("session telemetry state");
assert_eq!(state.error_provider_timeout, 2);
assert_eq!(state.error_tool_error, 1);
let errors = current_error_counts(state);
assert_eq!(errors.provider_timeout, 2);
assert_eq!(errors.tool_error, 1);
}
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
}
#[test]
fn test_error_counter_caps_per_session() {
let _guard = lock_telemetry_test_state();
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
begin_session_with_mode("openai", "gpt-5.4", None, false);
// A runaway retry loop once logged 18k+ auth failures in one session and
// distorted daily aggregates. The counter must saturate at the cap.
for _ in 0..600 {
record_error(ErrorCategory::AuthFailed);
}
{
let guard = SESSION_STATE.lock().unwrap();
let state = guard.as_ref().expect("session telemetry state");
assert_eq!(state.error_auth_failed, 500);
}
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
}
#[test]
fn test_error_counters_no_session_is_noop() {
let _guard = lock_telemetry_test_state();
// Errors recorded with no active session must not bump any counter that a
// future session could observe (issue #394: counts drifting across the
// session boundary).
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
record_error(ErrorCategory::AuthFailed);
record_provider_switch();
record_model_switch();
begin_session_with_mode("openai", "gpt-5.4", None, false);
{
let guard = SESSION_STATE.lock().unwrap();
let state = guard.as_ref().expect("session telemetry state");
assert_eq!(state.error_auth_failed, 0);
assert_eq!(state.provider_switches, 0);
assert_eq!(state.model_switches, 0);
}
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
}
#[test]
fn test_session_reason_labels() {
assert_eq!(SessionEndReason::NormalExit.as_str(), "normal_exit");
assert_eq!(SessionEndReason::Disconnect.as_str(), "disconnect");
}
#[test]
fn test_session_start_event_serialization() {
let event = SessionStartEvent {
event_id: "event-1".to_string(),
id: "test-uuid".to_string(),
session_id: "session-1".to_string(),
event: "session_start",
version: "0.6.1".to_string(),
os: "linux",
arch: "x86_64",
provider_start: "claude".to_string(),
model_start: "claude-sonnet-4".to_string(),
resumed_session: true,
session_start_hour_utc: 13,
session_start_weekday_utc: 2,
previous_session_gap_secs: Some(3600),
sessions_started_24h: 3,
sessions_started_7d: 8,
active_sessions_at_start: 2,
other_active_sessions_at_start: 1,
schema_version: TELEMETRY_SCHEMA_VERSION,
build_channel: "release".to_string(),
is_git_checkout: false,
is_ci: false,
ran_from_cargo: false,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["event"], "session_start");
assert_eq!(json["resumed_session"], true);
assert_eq!(json["session_id"], "session-1");
assert_eq!(json["sessions_started_24h"], 3);
}
#[test]
fn test_discovery_event_serialization_excludes_free_text() {
let event = DiscoveryEvent {
event_id: "event-discovery-1".to_string(),
id: "test-uuid".to_string(),
session_id: Some("session-1".to_string()),
event: "discovery",
version: "0.41.0".to_string(),
os: "linux",
arch: "x86_64",
request_id: "request-1".to_string(),
phase: "select".to_string(),
category: Some("payments".to_string()),
selected_tool: Some("agentcard".to_string()),
outcome: "success".to_string(),
failure_reason: None,
http_status: Some(200),
latency_ms: 123,
response_bytes: Some(456),
result_count: Some(1),
query_present: true,
reason_present: true,
custom_endpoint: false,
schema_version: TELEMETRY_SCHEMA_VERSION,
build_channel: "release".to_string(),
is_git_checkout: false,
is_ci: false,
ran_from_cargo: false,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["event"], "discovery");
assert_eq!(json["request_id"], "request-1");
assert_eq!(json["phase"], "select");
assert_eq!(json["selected_tool"], "agentcard");
assert_eq!(json["latency_ms"], 123);
assert!(json.get("query").is_none());
assert!(json.get("reason").is_none());
}
#[test]
fn test_session_end_event_serialization() {
let event = SessionLifecycleEvent {
event_id: "event-2".to_string(),
id: "test-uuid".to_string(),
session_id: "session-2".to_string(),
event: "session_end",
version: "0.6.1".to_string(),
os: "linux",
arch: "x86_64",
provider_start: "claude".to_string(),
provider_end: "openrouter".to_string(),
model_start: "claude-sonnet-4-20250514".to_string(),
model_end: "anthropic/claude-sonnet-4".to_string(),
provider_switches: 1,
model_switches: 2,
duration_mins: 45,
duration_secs: 2700,
turns: 23,
had_user_prompt: true,
had_assistant_response: true,
assistant_responses: 3,
first_assistant_response_ms: Some(1200),
first_tool_call_ms: Some(900),
first_tool_success_ms: Some(1500),
first_file_edit_ms: Some(2200),
first_test_pass_ms: Some(4100),
tool_calls: 4,
tool_failures: 1,
executed_tool_calls: 5,
executed_tool_successes: 4,
executed_tool_failures: 1,
tool_latency_total_ms: 3200,
tool_latency_max_ms: 1400,
file_write_calls: 2,
tests_run: 1,
tests_passed: 1,
input_tokens: 1234,
output_tokens: 567,
cache_read_input_tokens: 890,
cache_creation_input_tokens: 12,
total_tokens: 2703,
feature_memory_used: true,
feature_swarm_used: false,
feature_web_used: true,
feature_email_used: false,
feature_mcp_used: true,
feature_side_panel_used: true,
feature_goal_used: false,
feature_selfdev_used: false,
feature_background_used: false,
feature_subagent_used: true,
unique_mcp_servers: 2,
session_success: true,
abandoned_before_response: false,
session_stop_reason: "completed_successfully",
agent_role: "foreground",
parent_session_id: None,
agent_active_ms_total: 180_000,
agent_model_ms_total: 120_000,
agent_tool_ms_total: 60_000,
session_idle_ms_total: 30_000,
agent_blocked_ms_total: 0,
time_to_first_agent_action_ms: Some(900),
time_to_first_useful_action_ms: Some(1500),
spawned_agent_count: 3,
background_task_count: 1,
background_task_completed_count: 1,
subagent_task_count: 1,
subagent_success_count: 1,
swarm_task_count: 1,
swarm_success_count: 0,
user_cancelled_count: 1,
transport_https: 2,
transport_persistent_ws_fresh: 1,
transport_persistent_ws_reuse: 5,
transport_cli_subprocess: 0,
transport_native_http2: 0,
transport_other: 0,
tool_cat_read_search: 2,
tool_cat_write: 2,
tool_cat_shell: 1,
tool_cat_web: 1,
tool_cat_memory: 1,
tool_cat_subagent: 1,
tool_cat_swarm: 0,
tool_cat_email: 0,
tool_cat_side_panel: 1,
tool_cat_goal: 0,
tool_cat_mcp: 1,
tool_cat_other: 0,
command_login_used: false,
command_model_used: true,
command_usage_used: false,
command_resume_used: false,
command_memory_used: true,
command_swarm_used: false,
command_goal_used: false,
command_selfdev_used: false,
command_feedback_used: false,
command_other_used: false,
workflow_chat_only: false,
workflow_coding_used: true,
workflow_research_used: true,
workflow_tests_used: true,
workflow_background_used: false,
workflow_subagent_used: true,
workflow_swarm_used: false,
project_repo_present: true,
project_lang_rust: true,
project_lang_js_ts: false,
project_lang_python: false,
project_lang_go: false,
project_lang_markdown: true,
project_lang_mixed: true,
days_since_install: Some(12),
active_days_7d: 4,
active_days_30d: 9,
session_start_hour_utc: 13,
session_start_weekday_utc: 2,
session_end_hour_utc: 14,
session_end_weekday_utc: 2,
previous_session_gap_secs: Some(1800),
sessions_started_24h: 5,
sessions_started_7d: 12,
active_sessions_at_start: 2,
other_active_sessions_at_start: 1,
max_concurrent_sessions: 3,
multi_sessioned: true,
resumed_session: false,
end_reason: "normal_exit",
schema_version: TELEMETRY_SCHEMA_VERSION,
build_channel: "release".to_string(),
is_git_checkout: false,
is_ci: false,
ran_from_cargo: false,
errors: ErrorCounts {
provider_timeout: 2,
auth_failed: 0,
tool_error: 1,
mcp_error: 0,
rate_limited: 0,
},
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["event"], "session_end");
assert_eq!(json["assistant_responses"], 3);
assert_eq!(json["duration_secs"], 2700);
assert_eq!(json["executed_tool_calls"], 5);
assert_eq!(json["transport_https"], 2);
assert_eq!(json["tool_cat_write"], 2);
assert_eq!(json["workflow_coding_used"], true);
assert_eq!(json["active_days_30d"], 9);
assert_eq!(json["transport_persistent_ws_reuse"], 5);
assert_eq!(json["multi_sessioned"], true);
assert_eq!(json["end_reason"], "normal_exit");
assert_eq!(json["input_tokens"], 1234);
assert_eq!(json["output_tokens"], 567);
assert_eq!(json["cache_read_input_tokens"], 890);
assert_eq!(json["cache_creation_input_tokens"], 12);
assert_eq!(json["total_tokens"], 2703);
assert_eq!(json["errors"]["provider_timeout"], 2);
assert_eq!(json["session_stop_reason"], "completed_successfully");
assert_eq!(json["agent_active_ms_total"], 180_000);
assert_eq!(json["time_to_first_useful_action_ms"], 1500);
assert_eq!(json["subagent_task_count"], 1);
assert_eq!(json["user_cancelled_count"], 1);
}
#[test]
fn test_record_token_usage_aggregates_session_and_turn() {
let _guard = lock_telemetry_test_state();
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
begin_session_with_mode("openai", "gpt-5.4", None, false);
record_turn();
record_token_usage(100, 25, Some(200), Some(10));
record_token_usage(50, 5, None, Some(2));
{
let guard = SESSION_STATE.lock().unwrap();
let state = guard.as_ref().expect("session telemetry state");
assert_eq!(state.input_tokens, 150);
assert_eq!(state.output_tokens, 30);
assert_eq!(state.cache_read_input_tokens, 200);
assert_eq!(state.cache_creation_input_tokens, 12);
assert_eq!(state.total_tokens, 392);
let turn = state.current_turn.as_ref().expect("current turn");
assert_eq!(turn.input_tokens, 150);
assert_eq!(turn.output_tokens, 30);
assert_eq!(turn.cache_read_input_tokens, 200);
assert_eq!(turn.cache_creation_input_tokens, 12);
assert_eq!(turn.total_tokens, 392);
}
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
}
#[test]
fn test_record_connection_type_buckets_transport() {
let _guard = lock_telemetry_test_state();
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
begin_session_with_mode("openai", "gpt-5.4", None, false);
record_connection_type("websocket/persistent-fresh");
record_connection_type("websocket/persistent-reuse");
record_connection_type("https/sse");
record_connection_type("native http2");
record_connection_type("cli subprocess");
record_connection_type("weird-transport");
{
let guard = SESSION_STATE.lock().unwrap();
let state = guard.as_ref().expect("session telemetry state");
assert_eq!(state.transport_persistent_ws_fresh, 1);
assert_eq!(state.transport_persistent_ws_reuse, 1);
assert_eq!(state.transport_https, 1);
assert_eq!(state.transport_native_http2, 1);
assert_eq!(state.transport_cli_subprocess, 1);
assert_eq!(state.transport_other, 1);
}
if let Ok(mut session) = SESSION_STATE.lock() {
*session = None;
}
}
#[test]
fn test_sanitize_telemetry_label_strips_ansi_and_controls() {
assert_eq!(
sanitize_telemetry_label("\u{1b}[1mclaude-opus-4-6\u{1b}[0m\n"),
"claude-opus-4-6"
);
}
#[test]
fn test_onboarding_step_event_serialization_includes_failure_reason() {
let event = OnboardingStepEvent {
event_id: "event-3".to_string(),
id: "test-uuid".to_string(),
session_id: None,
event: "onboarding_step",
version: "0.6.1".to_string(),
os: "linux",
arch: "x86_64",
step: "auth_failed",
auth_provider: Some("openai".to_string()),
auth_method: Some("oauth".to_string()),
auth_failure_reason: Some("callback_timeout".to_string()),
milestone_elapsed_ms: Some(1234),
schema_version: TELEMETRY_SCHEMA_VERSION,
build_channel: "release".to_string(),
is_git_checkout: false,
is_ci: false,
ran_from_cargo: false,
};
let json = serde_json::to_value(&event).unwrap();
assert_eq!(json["step"], "auth_failed");
assert_eq!(json["auth_failure_reason"], "callback_timeout");
}
#[test]
fn test_onboarding_step_milestone_key_includes_provider_and_method() {
assert_eq!(
onboarding_step_milestone_key("auth_success", Some("jcode"), Some("API key")),
"auth_success_jcode_api_key"
);
assert_eq!(
onboarding_step_milestone_key("login_picker_opened", None, None),
"login_picker_opened"
);
}
#[test]
fn test_install_marker_tracks_current_telemetry_id() {
let _guard = lock_test_env();
let prev_home = std::env::var_os("JCODE_HOME");
let temp = tempfile::TempDir::new().expect("create temp dir");
jcode_core::env::set_var("JCODE_HOME", temp.path());
assert!(!install_recorded_for_id("id-a"));
mark_install_recorded("id-a");
assert!(install_recorded_for_id("id-a"));
assert!(!install_recorded_for_id("id-b"));
if let Some(prev_home) = prev_home {
jcode_core::env::set_var("JCODE_HOME", prev_home);
} else {
jcode_core::env::remove_var("JCODE_HOME");
}
}