[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:
jameswt-oai authored and GitHub committed 2026-06-15 16:32:38 -07:00
1 parent 1d8ff89aa3
commit e512e884ed
4 files changed
+286 -8

No files matched your search

+155
View File
@@ -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),