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
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:
@@ -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()
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user