mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08: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:
committed by
GitHub
Unverified
parent
1d8ff89aa3
commit
e512e884ed
@@ -0,0 +1,34 @@
|
||||
use crate::events::TrackEventsRequest;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR: &str =
|
||||
"CODEX_ANALYTICS_EVENTS_CAPTURE_FILE";
|
||||
|
||||
pub(crate) fn initialize(path: &Path) -> io::Result<()> {
|
||||
open_capture_file(path).map(drop)
|
||||
}
|
||||
|
||||
pub(crate) fn append_payload(path: &Path, payload: &TrackEventsRequest) -> io::Result<()> {
|
||||
let mut line = serde_json::to_vec(payload)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
line.push(b'\n');
|
||||
|
||||
let mut file = open_capture_file(path)?;
|
||||
file.write_all(&line)?;
|
||||
file.flush()
|
||||
}
|
||||
|
||||
fn open_capture_file(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
options.open(path)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ use codex_login::default_client::create_client;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -59,15 +60,70 @@ pub struct AnalyticsEventsClient {
|
||||
queue: Option<AnalyticsEventsQueue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
enum AnalyticsEventsDestination {
|
||||
Http {
|
||||
url: String,
|
||||
},
|
||||
#[cfg(debug_assertions)]
|
||||
CaptureFile {
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl AnalyticsEventsDestination {
|
||||
fn from_base_url(base_url: String) -> Self {
|
||||
let capture_file = analytics_capture_file_from_env();
|
||||
Self::from_base_url_and_capture_file(base_url, capture_file)
|
||||
}
|
||||
|
||||
fn from_base_url_and_capture_file(base_url: String, capture_file: Option<PathBuf>) -> Self {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(path) = capture_file {
|
||||
if let Err(err) = crate::analytics_capture::initialize(&path) {
|
||||
tracing::error!(
|
||||
path = %path.display(),
|
||||
"failed to initialize analytics event capture; network delivery remains disabled: {err}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"analytics event capture enabled; network delivery is disabled"
|
||||
);
|
||||
return Self::CaptureFile { path };
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
let _ = capture_file;
|
||||
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
Self::Http {
|
||||
url: format!("{base_url}/codex/analytics-events/events"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn analytics_capture_file_from_env() -> Option<PathBuf> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
std::env::var_os(crate::analytics_capture::ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(not(debug_assertions))]
|
||||
None
|
||||
}
|
||||
|
||||
impl AnalyticsEventsQueue {
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>, base_url: String) -> Self {
|
||||
fn new(auth_manager: Arc<AuthManager>, destination: AnalyticsEventsDestination) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
||||
tokio::spawn(async move {
|
||||
let mut reducer = AnalyticsReducer::default();
|
||||
while let Some(input) = receiver.recv().await {
|
||||
let mut events = Vec::new();
|
||||
reducer.ingest(input, &mut events).await;
|
||||
send_track_events(&auth_manager, &base_url, events).await;
|
||||
send_track_events(&auth_manager, &destination, events).await;
|
||||
}
|
||||
});
|
||||
Self {
|
||||
@@ -124,9 +180,10 @@ impl AnalyticsEventsClient {
|
||||
base_url: String,
|
||||
analytics_enabled: Option<bool>,
|
||||
) -> Self {
|
||||
let destination = AnalyticsEventsDestination::from_base_url(base_url);
|
||||
Self {
|
||||
queue: (analytics_enabled != Some(false))
|
||||
.then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url)),
|
||||
.then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), destination)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +467,7 @@ impl AnalyticsEventsClient {
|
||||
|
||||
async fn send_track_events(
|
||||
auth_manager: &AuthManager,
|
||||
base_url: &str,
|
||||
destination: &AnalyticsEventsDestination,
|
||||
events: Vec<TrackEventRequest>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
@@ -424,10 +481,8 @@ async fn send_track_events(
|
||||
return;
|
||||
}
|
||||
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/codex/analytics-events/events");
|
||||
for events in track_event_request_batches(events) {
|
||||
send_track_events_request(&auth, &url, events).await;
|
||||
send_track_events_request(&auth, destination, events).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,13 +509,27 @@ fn track_event_request_batches(events: Vec<TrackEventRequest>) -> Vec<Vec<TrackE
|
||||
batches
|
||||
}
|
||||
|
||||
async fn send_track_events_request(auth: &CodexAuth, url: &str, events: Vec<TrackEventRequest>) {
|
||||
async fn send_track_events_request(
|
||||
auth: &CodexAuth,
|
||||
destination: &AnalyticsEventsDestination,
|
||||
events: Vec<TrackEventRequest>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = TrackEventsRequest { events };
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
if capture_track_events_request(destination, &payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
let url = match destination {
|
||||
AnalyticsEventsDestination::Http { url } => url,
|
||||
#[cfg(debug_assertions)]
|
||||
AnalyticsEventsDestination::CaptureFile { .. } => return,
|
||||
};
|
||||
let response = create_client()
|
||||
.post(url)
|
||||
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
||||
@@ -483,6 +552,24 @@ async fn send_track_events_request(auth: &CodexAuth, url: &str, events: Vec<Trac
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
fn capture_track_events_request(
|
||||
destination: &AnalyticsEventsDestination,
|
||||
payload: &TrackEventsRequest,
|
||||
) -> bool {
|
||||
let AnalyticsEventsDestination::CaptureFile { path } = destination else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Err(err) = crate::analytics_capture::append_payload(path, payload) {
|
||||
tracing::error!(
|
||||
path = %path.display(),
|
||||
"failed to capture analytics events; network delivery remains disabled: {err}"
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod accepted_lines;
|
||||
#[cfg(debug_assertions)]
|
||||
mod analytics_capture;
|
||||
mod client;
|
||||
mod events;
|
||||
mod facts;
|
||||
|
||||
Reference in New Issue
Block a user