Codex Handbook
analytics/src/client_tests.rs 443 lines
use super::AnalyticsEventsClient;use super::AnalyticsEventsDestination;use super::AnalyticsEventsQueue;#[cfg(debug_assertions)]use super::capture_track_events_request;#[cfg(debug_assertions)]use super::send_track_events_request;use super::track_event_request_batches;use crate::events::CodexAcceptedLineFingerprintsEventParams;use crate::events::CodexAcceptedLineFingerprintsEventRequest;use crate::events::SkillInvocationEventParams;use crate::events::SkillInvocationEventRequest;use crate::events::TrackEventRequest;use crate::facts::AnalyticsFact;use crate::facts::InvocationType;use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;use codex_app_server_protocol::ClientRequest;use codex_app_server_protocol::ClientResponsePayload;use codex_app_server_protocol::RequestId;use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy;use codex_app_server_protocol::SessionSource as AppServerSessionSource;use codex_app_server_protocol::Thread;use codex_app_server_protocol::ThreadArchiveParams;use codex_app_server_protocol::ThreadArchiveResponse;use codex_app_server_protocol::ThreadForkResponse;use codex_app_server_protocol::ThreadResumeResponse;use codex_app_server_protocol::ThreadStartResponse;use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus;use codex_app_server_protocol::Turn;use codex_app_server_protocol::TurnStartParams;use codex_app_server_protocol::TurnStartResponse;use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;use codex_app_server_protocol::TurnSteerParams;use codex_app_server_protocol::TurnSteerResponse;use codex_utils_absolute_path::test_support::PathBufExt;use codex_utils_absolute_path::test_support::test_path_buf;use std::collections::HashSet;#[cfg(debug_assertions)]use std::fs;#[cfg(debug_assertions)]use std::path::PathBuf;use std::sync::Arc;use std::sync::Mutex;#[cfg(debug_assertions)]use std::time::SystemTime;use tokio::sync::mpsc;use tokio::sync::mpsc::error::TryRecvError;fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest {    TrackEventRequest::AcceptedLineFingerprints(Box::new(        CodexAcceptedLineFingerprintsEventRequest {            event_type: "codex_accepted_line_fingerprints",            event_params: CodexAcceptedLineFingerprintsEventParams {                event_type: "codex.accepted_line_fingerprints",                turn_id: "turn-1".to_string(),                thread_id: thread_id.to_string(),                product_surface: Some("codex".to_string()),                model_slug: Some("gpt-5.1-codex".to_string()),                completed_at: 1,                repo_hash: None,                accepted_added_lines: 1,                accepted_deleted_lines: 0,                line_fingerprints: Vec::new(),            },        },    ))}fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {    TrackEventRequest::SkillInvocation(SkillInvocationEventRequest {        event_type: "skill_invocation",        skill_id: format!("skill-{thread_id}"),        skill_name: "doc".to_string(),        event_params: SkillInvocationEventParams {            product_client_id: None,            skill_scope: None,            plugin_id: None,            repo_url: None,            thread_id: Some(thread_id.to_string()),            turn_id: Some("turn-1".to_string()),            invoke_type: Some(InvocationType::Explicit),            model_slug: Some("gpt-5.1-codex".to_string()),        },    })}#[cfg(debug_assertions)]fn unique_capture_path(name: &str) -> PathBuf {    let nonce = SystemTime::now()        .duration_since(SystemTime::UNIX_EPOCH)        .expect("system clock should be after Unix epoch")        .as_nanos();    std::env::temp_dir().join(format!(        "codex-analytics-{name}-{}-{nonce}.jsonl",        std::process::id()    ))}fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver<AnalyticsFact>) {    let (sender, receiver) = mpsc::channel(8);    let queue = AnalyticsEventsQueue {        sender,        app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),        plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),    };    (AnalyticsEventsClient { queue: Some(queue) }, receiver)}#[test]#[cfg(debug_assertions)]fn analytics_destination_uses_explicit_capture_file() {    let capture_path = unique_capture_path("destination");    let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(        "https://chatgpt.com/backend-api/".to_string(),        Some(capture_path.clone()),    );    assert_eq!(        destination,        AnalyticsEventsDestination::CaptureFile {            path: capture_path.clone()        }    );    assert_eq!(        fs::read_to_string(&capture_path).expect("read capture file"),        ""    );    #[cfg(unix)]    {        use std::os::unix::fs::PermissionsExt;        let mode = fs::metadata(&capture_path)            .expect("read capture file metadata")            .permissions()            .mode();        assert_eq!(mode & 0o777, 0o600);    }    fs::remove_file(capture_path).expect("remove capture file");}#[test]fn analytics_destination_uses_http_without_capture_file() {    let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(        "https://chatgpt.com/backend-api/".to_string(),        /*capture_file*/ None,    );    assert_eq!(        destination,        AnalyticsEventsDestination::Http {            url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()        }    );}#[test]#[cfg(not(debug_assertions))]fn analytics_destination_ignores_capture_file_in_release() {    let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(        "https://chatgpt.com/backend-api/".to_string(),        Some(std::path::PathBuf::from("ignored.jsonl")),    );    assert_eq!(        destination,        AnalyticsEventsDestination::Http {            url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()        }    );}#[tokio::test]#[cfg(debug_assertions)]async fn capture_file_writes_exact_serialized_request() {    let capture_path = unique_capture_path("single");    let destination = AnalyticsEventsDestination::CaptureFile {        path: capture_path.clone(),    };    let event = sample_regular_track_event("thread-1");    let expected_event = serde_json::to_value(&event).expect("serialize expected event");    let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();    send_track_events_request(&auth, &destination, vec![event]).await;    let contents = fs::read_to_string(&capture_path).expect("read capture file");    let lines = contents.lines().collect::<Vec<_>>();    assert_eq!(lines.len(), 1);    let payload: serde_json::Value =        serde_json::from_str(lines[0]).expect("parse captured payload");    assert_eq!(payload, serde_json::json!({"events": [expected_event]}));    fs::remove_file(capture_path).expect("remove capture file");}#[tokio::test]#[cfg(debug_assertions)]async fn capture_file_writes_final_batches_as_separate_lines() {    let capture_path = unique_capture_path("batches");    let destination = AnalyticsEventsDestination::CaptureFile {        path: capture_path.clone(),    };    let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();    let events = vec![        sample_regular_track_event("thread-1"),        sample_accepted_line_fingerprint_event("thread-2"),        sample_regular_track_event("thread-3"),    ];    for batch in track_event_request_batches(events) {        send_track_events_request(&auth, &destination, batch).await;    }    let contents = fs::read_to_string(&capture_path).expect("read capture file");    let payloads = contents        .lines()        .map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse capture line"))        .collect::<Vec<_>>();    assert_eq!(payloads.len(), 3);    assert_eq!(payloads[0]["events"][0]["skill_id"], "skill-thread-1");    assert_eq!(        payloads[1]["events"][0]["event_type"],        "codex_accepted_line_fingerprints"    );    assert_eq!(payloads[2]["events"][0]["skill_id"], "skill-thread-3");    fs::remove_file(capture_path).expect("remove capture file");}#[test]#[cfg(debug_assertions)]fn capture_write_failure_still_consumes_delivery() {    let capture_path = unique_capture_path("missing-parent").join("events.jsonl");    let destination = AnalyticsEventsDestination::CaptureFile { path: capture_path };    let payload = crate::events::TrackEventsRequest {        events: vec![sample_regular_track_event("thread-1")],    };    assert!(capture_track_events_request(&destination, &payload));}fn sample_turn_start_request() -> ClientRequest {    ClientRequest::TurnStart {        request_id: RequestId::Integer(1),        params: TurnStartParams {            thread_id: "thread-1".to_string(),            client_user_message_id: None,            input: Vec::new(),            ..Default::default()        },    }}fn sample_turn_steer_request() -> ClientRequest {    ClientRequest::TurnSteer {        request_id: RequestId::Integer(2),        params: TurnSteerParams {            thread_id: "thread-1".to_string(),            expected_turn_id: "turn-1".to_string(),            client_user_message_id: None,            input: Vec::new(),            responsesapi_client_metadata: None,            additional_context: None,        },    }}fn sample_thread_archive_request() -> ClientRequest {    ClientRequest::ThreadArchive {        request_id: RequestId::Integer(3),        params: ThreadArchiveParams {            thread_id: "thread-1".to_string(),        },    }}fn sample_thread(thread_id: &str) -> Thread {    Thread {        id: thread_id.to_string(),        session_id: format!("session-{thread_id}"),        forked_from_id: None,        parent_thread_id: None,        preview: "first prompt".to_string(),        ephemeral: false,        model_provider: "openai".to_string(),        created_at: 1,        updated_at: 2,        status: AppServerThreadStatus::Idle,        path: None,        cwd: test_path_buf("/tmp").abs(),        cli_version: "0.0.0".to_string(),        source: AppServerSessionSource::Exec,        thread_source: None,        agent_nickname: None,        agent_role: None,        git_info: None,        name: None,        turns: Vec::new(),    }}fn sample_thread_start_response() -> ClientResponsePayload {    ClientResponsePayload::ThreadStart(ThreadStartResponse {        thread: sample_thread("thread-1"),        model: "gpt-5".to_string(),        model_provider: "openai".to_string(),        service_tier: None,        cwd: test_path_buf("/tmp").abs(),        runtime_workspace_roots: Vec::new(),        instruction_sources: Vec::new(),        approval_policy: AppServerAskForApproval::OnFailure,        approvals_reviewer: AppServerApprovalsReviewer::User,        sandbox: AppServerSandboxPolicy::DangerFullAccess,        active_permission_profile: None,        reasoning_effort: None,    })}fn sample_thread_resume_response() -> ClientResponsePayload {    ClientResponsePayload::ThreadResume(ThreadResumeResponse {        thread: sample_thread("thread-2"),        model: "gpt-5".to_string(),        model_provider: "openai".to_string(),        service_tier: None,        cwd: test_path_buf("/tmp").abs(),        runtime_workspace_roots: Vec::new(),        instruction_sources: Vec::new(),        approval_policy: AppServerAskForApproval::OnFailure,        approvals_reviewer: AppServerApprovalsReviewer::User,        sandbox: AppServerSandboxPolicy::DangerFullAccess,        active_permission_profile: None,        reasoning_effort: None,        initial_turns_page: None,    })}fn sample_thread_fork_response() -> ClientResponsePayload {    ClientResponsePayload::ThreadFork(ThreadForkResponse {        thread: sample_thread("thread-3"),        model: "gpt-5".to_string(),        model_provider: "openai".to_string(),        service_tier: None,        cwd: test_path_buf("/tmp").abs(),        runtime_workspace_roots: Vec::new(),        instruction_sources: Vec::new(),        approval_policy: AppServerAskForApproval::OnFailure,        approvals_reviewer: AppServerApprovalsReviewer::User,        sandbox: AppServerSandboxPolicy::DangerFullAccess,        active_permission_profile: None,        reasoning_effort: None,    })}fn sample_turn_start_response() -> ClientResponsePayload {    ClientResponsePayload::TurnStart(TurnStartResponse {        turn: Turn {            id: "turn-1".to_string(),            items_view: codex_app_server_protocol::TurnItemsView::Full,            items: Vec::new(),            status: AppServerTurnStatus::InProgress,            error: None,            started_at: None,            completed_at: None,            duration_ms: None,        },    })}fn sample_turn_steer_response() -> ClientResponsePayload {    ClientResponsePayload::TurnSteer(TurnSteerResponse {        turn_id: "turn-2".to_string(),    })}#[test]fn track_request_only_enqueues_analytics_relevant_requests() {    let (client, mut receiver) = client_with_receiver();    for (request_id, request) in [        (RequestId::Integer(1), sample_turn_start_request()),        (RequestId::Integer(2), sample_turn_steer_request()),    ] {        client.track_request(/*connection_id*/ 7, request_id, &request);        assert!(matches!(            receiver.try_recv(),            Ok(AnalyticsFact::ClientRequest { .. })        ));    }    let ignored_request = sample_thread_archive_request();    client.track_request(        /*connection_id*/ 7,        RequestId::Integer(3),        &ignored_request,    );    assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));}#[test]fn track_response_only_enqueues_analytics_relevant_responses() {    let (client, mut receiver) = client_with_receiver();    for (request_id, response) in [        (RequestId::Integer(1), sample_thread_start_response()),        (RequestId::Integer(2), sample_thread_resume_response()),        (RequestId::Integer(3), sample_thread_fork_response()),        (RequestId::Integer(4), sample_turn_start_response()),        (RequestId::Integer(5), sample_turn_steer_response()),    ] {        client.track_response(/*connection_id*/ 7, request_id, response);        assert!(matches!(            receiver.try_recv(),            Ok(AnalyticsFact::ClientResponse { .. })        ));    }    client.track_response(        /*connection_id*/ 7,        RequestId::Integer(6),        ClientResponsePayload::ThreadArchive(ThreadArchiveResponse {}),    );    assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));}#[test]fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() {    let batches = track_event_request_batches(vec![        sample_regular_track_event("thread-1"),        sample_regular_track_event("thread-2"),        sample_accepted_line_fingerprint_event("thread-3"),        sample_accepted_line_fingerprint_event("thread-4"),        sample_regular_track_event("thread-5"),        sample_regular_track_event("thread-6"),    ]);    assert_eq!(batches.len(), 4);    assert_eq!(batches[0].len(), 2);    assert_eq!(batches[1].len(), 1);    assert_eq!(batches[2].len(), 1);    assert_eq!(batches[3].len(), 2);    assert!(batches[1][0].should_send_in_isolated_request());    assert!(batches[2][0].should_send_in_isolated_request());}