mirror of
https://github.com/openai/codex.git
synced 2026-11-07 16:35:32 +00:00
[codex-analytics] Analytics Capture to File in Debug Builds (#27093)
## This PR The original [combined remote plugin analytics PR #26281](https://github.com/openai/codex/pull/26281) mixed reusable analytics test infrastructure, two manual smoke workflows, a metadata refactor, and the final identity behavior. This PR isolates the generic capture mechanism so it can be reviewed and landed before any plugin-specific behavior. - Add a debug-only analytics destination that writes final request payloads as JSONL. - Suppress HTTP delivery whenever capture mode is selected, including after capture write failures. - Keep release behavior unchanged even when the capture environment variable is present. - Keep the mechanism generic; this PR contains no plugin-specific behavior. Set `CODEX_ANALYTICS_EVENTS_CAPTURE_FILE=/path/events.jsonl` when running a debug Codex binary to inspect the exact batched payload that would otherwise be sent to the analytics endpoint. ## Testing - `just test -p codex-analytics` (76 passed) - `just test --release -p codex-analytics` (73 passed) - CI is green across the required platform matrix. ## Split Overview ```text main ├── #27093 Debug analytics capture ← you are here │ └── #27099 Non-mutating plugin smoke │ └── #27100 Remote install/uninstall smoke └── #27102 Plugin telemetry metadata refactor After #27093, #27099, #27100, and #27102 merge: └── Final PR: add remote_plugin_id to plugin analytics ``` Review order and dependencies: 1. [#27093 Add debug-only analytics event capture](https://github.com/openai/codex/pull/27093) **(this PR, based on `main`)** 2. [#27099 Add a plugin analytics smoke workflow](https://github.com/openai/codex/pull/27099) (stacked on #27093) 3. [#27100 Add a remote plugin analytics mutation smoke workflow](https://github.com/openai/codex/pull/27100) (stacked on #27099) 4. [#27102 Centralize plugin telemetry metadata construction](https://github.com/openai/codex/pull/27102) (independent, based on `main`) 5. Final remote-ID behavior PR (created after PRs 1-4 merge) The original [#26281](https://github.com/openai/codex/pull/26281) remains open as the green aggregate reference until the final PR is published.
This commit is contained in:
1 parent
1d8ff89aa3
commit
e512e884ed
4 files changed
+286
-8
No files matched your search
@@ -1,5 +1,10 @@
|
||||
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;
|
||||
@@ -31,8 +36,14 @@ 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;
|
||||
|
||||
@@ -74,6 +85,18 @@ fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {
|
||||
})
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -84,6 +107,138 @@ fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver<AnalyticsFac
|
||||
(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),
|
||||
|
||||
Reference in new issue
Block a user