mirror of
https://github.com/openai/codex.git
synced 2026-04-27 08:05:51 +00:00
- move the analytics events client into codex-analytics - update codex-core and app-server callsites to use the new crate - CI --------- Co-authored-by: Codex <noreply@openai.com>
1143 lines
35 KiB
Rust
1143 lines
35 KiB
Rust
use codex_git_utils::collect_git_info;
|
|
use codex_git_utils::get_git_repo_root;
|
|
use codex_login::AuthManager;
|
|
use codex_login::default_client::create_client;
|
|
use codex_login::default_client::originator;
|
|
use codex_plugin::PluginTelemetryMetadata;
|
|
use codex_protocol::config_types::ApprovalsReviewer;
|
|
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::config_types::Personality;
|
|
use codex_protocol::config_types::ReasoningSummary;
|
|
use codex_protocol::config_types::ServiceTier;
|
|
use codex_protocol::openai_models::ReasoningEffort;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SkillScope;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
use codex_protocol::protocol::SubmissionType;
|
|
use serde::Serialize;
|
|
use sha1::Digest;
|
|
use sha1::Sha1;
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
use tokio::sync::mpsc;
|
|
|
|
#[derive(Clone)]
|
|
pub struct TrackEventsContext {
|
|
pub model_slug: String,
|
|
pub thread_id: String,
|
|
pub turn_id: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CodexThreadInitializedEvent {
|
|
pub thread_id: String,
|
|
pub model: String,
|
|
pub model_provider: String,
|
|
pub reasoning_effort: Option<ReasoningEffort>,
|
|
pub reasoning_summary: Option<ReasoningSummary>,
|
|
pub service_tier: Option<ServiceTier>,
|
|
pub approval_policy: AskForApproval,
|
|
pub approvals_reviewer: ApprovalsReviewer,
|
|
pub sandbox_policy: SandboxPolicy,
|
|
pub sandbox_network_access: bool,
|
|
pub collaboration_mode: ModeKind,
|
|
pub personality: Option<Personality>,
|
|
pub ephemeral: bool,
|
|
pub session_source: SessionSource,
|
|
pub initialization_mode: InitializationMode,
|
|
pub subagent_source: Option<SubAgentSource>,
|
|
pub parent_thread_id: Option<String>,
|
|
pub created_at: u64,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CodexTurnEvent {
|
|
pub submission_type: Option<SubmissionType>,
|
|
pub model_provider: String,
|
|
pub sandbox_policy: SandboxPolicy,
|
|
pub reasoning_effort: Option<ReasoningEffort>,
|
|
pub reasoning_summary: Option<ReasoningSummary>,
|
|
pub service_tier: Option<ServiceTier>,
|
|
pub approval_policy: AskForApproval,
|
|
pub approvals_reviewer: ApprovalsReviewer,
|
|
pub sandbox_network_access: bool,
|
|
pub collaboration_mode: ModeKind,
|
|
pub personality: Option<Personality>,
|
|
pub num_input_images: usize,
|
|
pub is_first_turn: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum InitializationMode {
|
|
New,
|
|
Forked,
|
|
Resumed,
|
|
}
|
|
|
|
pub fn build_track_events_context(
|
|
model_slug: String,
|
|
thread_id: String,
|
|
turn_id: String,
|
|
) -> TrackEventsContext {
|
|
TrackEventsContext {
|
|
model_slug,
|
|
thread_id,
|
|
turn_id,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct SkillInvocation {
|
|
pub skill_name: String,
|
|
pub skill_scope: SkillScope,
|
|
pub skill_path: PathBuf,
|
|
pub invocation_type: InvocationType,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Serialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum InvocationType {
|
|
Explicit,
|
|
Implicit,
|
|
}
|
|
|
|
pub struct AppInvocation {
|
|
pub connector_id: Option<String>,
|
|
pub app_name: Option<String>,
|
|
pub invocation_type: Option<InvocationType>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct AnalyticsEventsQueue {
|
|
sender: mpsc::Sender<TrackEventsJob>,
|
|
app_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
|
plugin_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AnalyticsEventsClient {
|
|
queue: AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
}
|
|
|
|
impl AnalyticsEventsQueue {
|
|
pub(crate) fn new(auth_manager: Arc<AuthManager>, base_url: String) -> Self {
|
|
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
|
tokio::spawn(async move {
|
|
while let Some(job) = receiver.recv().await {
|
|
match job {
|
|
TrackEventsJob::SkillInvocations(job) => {
|
|
send_track_skill_invocations(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::ThreadInitialized(job) => {
|
|
send_track_thread_initialized(&auth_manager, job).await;
|
|
}
|
|
TrackEventsJob::AppMentioned(job) => {
|
|
send_track_app_mentioned(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::AppUsed(job) => {
|
|
send_track_app_used(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::TurnEvent(job) => {
|
|
send_track_turn_event(&auth_manager, job).await;
|
|
}
|
|
TrackEventsJob::PluginUsed(job) => {
|
|
send_track_plugin_used(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::PluginInstalled(job) => {
|
|
send_track_plugin_installed(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::PluginUninstalled(job) => {
|
|
send_track_plugin_uninstalled(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::PluginEnabled(job) => {
|
|
send_track_plugin_enabled(&auth_manager, &base_url, job).await;
|
|
}
|
|
TrackEventsJob::PluginDisabled(job) => {
|
|
send_track_plugin_disabled(&auth_manager, &base_url, job).await;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
Self {
|
|
sender,
|
|
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
|
}
|
|
}
|
|
|
|
fn try_send(&self, job: TrackEventsJob) {
|
|
if self.sender.try_send(job).is_err() {
|
|
//TODO: add a metric for this
|
|
tracing::warn!("dropping analytics events: queue is full");
|
|
}
|
|
}
|
|
|
|
fn should_enqueue_app_used(&self, tracking: &TrackEventsContext, app: &AppInvocation) -> bool {
|
|
let Some(connector_id) = app.connector_id.as_ref() else {
|
|
return true;
|
|
};
|
|
let mut emitted = self
|
|
.app_used_emitted_keys
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
|
emitted.clear();
|
|
}
|
|
emitted.insert((tracking.turn_id.clone(), connector_id.clone()))
|
|
}
|
|
|
|
fn should_enqueue_plugin_used(
|
|
&self,
|
|
tracking: &TrackEventsContext,
|
|
plugin: &PluginTelemetryMetadata,
|
|
) -> bool {
|
|
let mut emitted = self
|
|
.plugin_used_emitted_keys
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
|
emitted.clear();
|
|
}
|
|
emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key()))
|
|
}
|
|
}
|
|
|
|
impl AnalyticsEventsClient {
|
|
pub fn new(
|
|
auth_manager: Arc<AuthManager>,
|
|
base_url: String,
|
|
analytics_enabled: Option<bool>,
|
|
) -> Self {
|
|
Self {
|
|
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url),
|
|
analytics_enabled,
|
|
}
|
|
}
|
|
|
|
pub fn track_skill_invocations(
|
|
&self,
|
|
tracking: TrackEventsContext,
|
|
invocations: Vec<SkillInvocation>,
|
|
) {
|
|
track_skill_invocations(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
Some(tracking),
|
|
invocations,
|
|
);
|
|
}
|
|
|
|
pub fn track_thread_initialized(&self, thread_event: CodexThreadInitializedEvent) {
|
|
track_thread_initialized(&self.queue, self.analytics_enabled, thread_event);
|
|
}
|
|
|
|
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
|
|
track_app_mentioned(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
Some(tracking),
|
|
mentions,
|
|
);
|
|
}
|
|
|
|
pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
|
track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app);
|
|
}
|
|
|
|
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
|
|
track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin);
|
|
}
|
|
|
|
pub(crate) fn track_turn_event(
|
|
&self,
|
|
tracking: TrackEventsContext,
|
|
turn_event: CodexTurnEvent,
|
|
) {
|
|
track_turn_event(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
Some(tracking),
|
|
turn_event,
|
|
);
|
|
}
|
|
|
|
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
|
|
track_plugin_management(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
PluginManagementEventType::Installed,
|
|
plugin,
|
|
);
|
|
}
|
|
|
|
pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
|
|
track_plugin_management(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
PluginManagementEventType::Uninstalled,
|
|
plugin,
|
|
);
|
|
}
|
|
|
|
pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
|
|
track_plugin_management(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
PluginManagementEventType::Enabled,
|
|
plugin,
|
|
);
|
|
}
|
|
|
|
pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
|
|
track_plugin_management(
|
|
&self.queue,
|
|
self.analytics_enabled,
|
|
PluginManagementEventType::Disabled,
|
|
plugin,
|
|
);
|
|
}
|
|
}
|
|
|
|
enum TrackEventsJob {
|
|
SkillInvocations(TrackSkillInvocationsJob),
|
|
ThreadInitialized(TrackThreadInitializedJob),
|
|
AppMentioned(TrackAppMentionedJob),
|
|
AppUsed(TrackAppUsedJob),
|
|
TurnEvent(TrackTurnEventJob),
|
|
PluginUsed(TrackPluginUsedJob),
|
|
PluginInstalled(TrackPluginManagementJob),
|
|
PluginUninstalled(TrackPluginManagementJob),
|
|
PluginEnabled(TrackPluginManagementJob),
|
|
PluginDisabled(TrackPluginManagementJob),
|
|
}
|
|
|
|
struct TrackSkillInvocationsJob {
|
|
analytics_enabled: Option<bool>,
|
|
tracking: TrackEventsContext,
|
|
invocations: Vec<SkillInvocation>,
|
|
}
|
|
|
|
struct TrackThreadInitializedJob {
|
|
analytics_enabled: Option<bool>,
|
|
thread_event: CodexThreadInitializedEvent,
|
|
}
|
|
|
|
struct TrackAppMentionedJob {
|
|
analytics_enabled: Option<bool>,
|
|
tracking: TrackEventsContext,
|
|
mentions: Vec<AppInvocation>,
|
|
}
|
|
|
|
struct TrackAppUsedJob {
|
|
analytics_enabled: Option<bool>,
|
|
tracking: TrackEventsContext,
|
|
app: AppInvocation,
|
|
}
|
|
|
|
struct TrackTurnEventJob {
|
|
analytics_enabled: Option<bool>,
|
|
tracking: TrackEventsContext,
|
|
turn_event: CodexTurnEvent,
|
|
}
|
|
|
|
struct TrackPluginUsedJob {
|
|
analytics_enabled: Option<bool>,
|
|
tracking: TrackEventsContext,
|
|
plugin: PluginTelemetryMetadata,
|
|
}
|
|
|
|
struct TrackPluginManagementJob {
|
|
analytics_enabled: Option<bool>,
|
|
plugin: PluginTelemetryMetadata,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum PluginManagementEventType {
|
|
Installed,
|
|
Uninstalled,
|
|
Enabled,
|
|
Disabled,
|
|
}
|
|
|
|
const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
|
|
const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
|
|
const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096;
|
|
|
|
#[derive(Serialize)]
|
|
struct TrackEventsRequest {
|
|
events: Vec<TrackEventRequest>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
#[serde(untagged)]
|
|
enum TrackEventRequest {
|
|
SkillInvocation(SkillInvocationEventRequest),
|
|
ThreadInitialized(CodexThreadInitializedEventRequest),
|
|
AppMentioned(CodexAppMentionedEventRequest),
|
|
AppUsed(CodexAppUsedEventRequest),
|
|
TurnEvent(CodexTurnEventRequest),
|
|
PluginUsed(CodexPluginUsedEventRequest),
|
|
PluginInstalled(CodexPluginEventRequest),
|
|
PluginUninstalled(CodexPluginEventRequest),
|
|
PluginEnabled(CodexPluginEventRequest),
|
|
PluginDisabled(CodexPluginEventRequest),
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SkillInvocationEventRequest {
|
|
event_type: &'static str,
|
|
skill_id: String,
|
|
skill_name: String,
|
|
event_params: SkillInvocationEventParams,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SkillInvocationEventParams {
|
|
product_client_id: Option<String>,
|
|
skill_scope: Option<String>,
|
|
repo_url: Option<String>,
|
|
thread_id: Option<String>,
|
|
invoke_type: Option<InvocationType>,
|
|
model_slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexThreadInitializedEventParams {
|
|
thread_id: String,
|
|
product_client_id: String,
|
|
model: String,
|
|
model_provider: String,
|
|
reasoning_effort: Option<String>,
|
|
reasoning_summary: Option<String>,
|
|
service_tier: String,
|
|
approval_policy: String,
|
|
approvals_reviewer: String,
|
|
sandbox_policy: &'static str,
|
|
sandbox_network_access: bool,
|
|
collaboration_mode: &'static str,
|
|
personality: Option<String>,
|
|
ephemeral: bool,
|
|
session_source: Option<&'static str>,
|
|
initialization_mode: InitializationMode,
|
|
subagent_source: Option<String>,
|
|
parent_thread_id: Option<String>,
|
|
created_at: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexThreadInitializedEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexThreadInitializedEventParams,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexAppMetadata {
|
|
connector_id: Option<String>,
|
|
thread_id: Option<String>,
|
|
turn_id: Option<String>,
|
|
app_name: Option<String>,
|
|
product_client_id: Option<String>,
|
|
invoke_type: Option<InvocationType>,
|
|
model_slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexAppMentionedEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexAppMetadata,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexAppUsedEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexAppMetadata,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexTurnEventParams {
|
|
thread_id: String,
|
|
turn_id: String,
|
|
product_client_id: Option<String>,
|
|
model: Option<String>,
|
|
submission_type: Option<SubmissionType>,
|
|
model_provider: String,
|
|
sandbox_policy: Option<&'static str>,
|
|
reasoning_effort: Option<String>,
|
|
reasoning_summary: Option<String>,
|
|
service_tier: String,
|
|
approval_policy: String,
|
|
approvals_reviewer: String,
|
|
sandbox_network_access: bool,
|
|
collaboration_mode: Option<&'static str>,
|
|
personality: Option<String>,
|
|
num_input_images: usize,
|
|
is_first_turn: bool,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexTurnEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexTurnEventParams,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexPluginMetadata {
|
|
plugin_id: Option<String>,
|
|
plugin_name: Option<String>,
|
|
marketplace_name: Option<String>,
|
|
has_skills: Option<bool>,
|
|
mcp_server_count: Option<usize>,
|
|
connector_ids: Option<Vec<String>>,
|
|
product_client_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexPluginUsedMetadata {
|
|
#[serde(flatten)]
|
|
plugin: CodexPluginMetadata,
|
|
thread_id: Option<String>,
|
|
turn_id: Option<String>,
|
|
model_slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexPluginEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexPluginMetadata,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CodexPluginUsedEventRequest {
|
|
event_type: &'static str,
|
|
event_params: CodexPluginUsedMetadata,
|
|
}
|
|
|
|
pub(crate) fn track_skill_invocations(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
tracking: Option<TrackEventsContext>,
|
|
invocations: Vec<SkillInvocation>,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let Some(tracking) = tracking else {
|
|
return;
|
|
};
|
|
if invocations.is_empty() {
|
|
return;
|
|
}
|
|
let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
invocations,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
pub(crate) fn track_thread_initialized(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
thread_event: CodexThreadInitializedEvent,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let job = TrackEventsJob::ThreadInitialized(TrackThreadInitializedJob {
|
|
analytics_enabled,
|
|
thread_event,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
pub(crate) fn track_app_mentioned(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
tracking: Option<TrackEventsContext>,
|
|
mentions: Vec<AppInvocation>,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let Some(tracking) = tracking else {
|
|
return;
|
|
};
|
|
if mentions.is_empty() {
|
|
return;
|
|
}
|
|
let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
mentions,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
pub(crate) fn track_app_used(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
tracking: Option<TrackEventsContext>,
|
|
app: AppInvocation,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let Some(tracking) = tracking else {
|
|
return;
|
|
};
|
|
if !queue.should_enqueue_app_used(&tracking, &app) {
|
|
return;
|
|
}
|
|
let job = TrackEventsJob::AppUsed(TrackAppUsedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
app,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
pub(crate) fn track_turn_event(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
tracking: Option<TrackEventsContext>,
|
|
turn_event: CodexTurnEvent,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let Some(tracking) = tracking else {
|
|
return;
|
|
};
|
|
let job = TrackEventsJob::TurnEvent(TrackTurnEventJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
turn_event,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
pub(crate) fn track_plugin_used(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
tracking: Option<TrackEventsContext>,
|
|
plugin: PluginTelemetryMetadata,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let Some(tracking) = tracking else {
|
|
return;
|
|
};
|
|
if !queue.should_enqueue_plugin_used(&tracking, &plugin) {
|
|
return;
|
|
}
|
|
let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
plugin,
|
|
});
|
|
queue.try_send(job);
|
|
}
|
|
|
|
fn track_plugin_management(
|
|
queue: &AnalyticsEventsQueue,
|
|
analytics_enabled: Option<bool>,
|
|
event_type: PluginManagementEventType,
|
|
plugin: PluginTelemetryMetadata,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
let job = TrackPluginManagementJob {
|
|
analytics_enabled,
|
|
plugin,
|
|
};
|
|
let job = match event_type {
|
|
PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job),
|
|
PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job),
|
|
PluginManagementEventType::Enabled => TrackEventsJob::PluginEnabled(job),
|
|
PluginManagementEventType::Disabled => TrackEventsJob::PluginDisabled(job),
|
|
};
|
|
queue.try_send(job);
|
|
}
|
|
|
|
async fn send_track_skill_invocations(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackSkillInvocationsJob,
|
|
) {
|
|
let TrackSkillInvocationsJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
invocations,
|
|
} = job;
|
|
let mut events = Vec::with_capacity(invocations.len());
|
|
for invocation in invocations {
|
|
let skill_scope = match invocation.skill_scope {
|
|
SkillScope::User => "user",
|
|
SkillScope::Repo => "repo",
|
|
SkillScope::System => "system",
|
|
SkillScope::Admin => "admin",
|
|
};
|
|
let repo_root = get_git_repo_root(invocation.skill_path.as_path());
|
|
let repo_url = if let Some(root) = repo_root.as_ref() {
|
|
collect_git_info(root)
|
|
.await
|
|
.and_then(|info| info.repository_url)
|
|
} else {
|
|
None
|
|
};
|
|
let skill_id = skill_id_for_local_skill(
|
|
repo_url.as_deref(),
|
|
repo_root.as_deref(),
|
|
invocation.skill_path.as_path(),
|
|
invocation.skill_name.as_str(),
|
|
);
|
|
events.push(TrackEventRequest::SkillInvocation(
|
|
SkillInvocationEventRequest {
|
|
event_type: "skill_invocation",
|
|
skill_id,
|
|
skill_name: invocation.skill_name.clone(),
|
|
event_params: SkillInvocationEventParams {
|
|
thread_id: Some(tracking.thread_id.clone()),
|
|
invoke_type: Some(invocation.invocation_type),
|
|
model_slug: Some(tracking.model_slug.clone()),
|
|
product_client_id: Some(originator().value),
|
|
repo_url,
|
|
skill_scope: Some(skill_scope.to_string()),
|
|
},
|
|
},
|
|
));
|
|
}
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_thread_initialized(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackThreadInitializedJob,
|
|
) {
|
|
let TrackThreadInitializedJob {
|
|
analytics_enabled,
|
|
thread_event,
|
|
} = job;
|
|
let events = vec![TrackEventRequest::ThreadInitialized(
|
|
CodexThreadInitializedEventRequest {
|
|
event_type: "codex_thread_initialized",
|
|
event_params: codex_thread_initialized_event_params(thread_event),
|
|
},
|
|
)];
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_app_mentioned(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackAppMentionedJob,
|
|
) {
|
|
let TrackAppMentionedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
mentions,
|
|
} = job;
|
|
let events = mentions
|
|
.into_iter()
|
|
.map(|mention| {
|
|
let event_params = codex_app_metadata(&tracking, mention);
|
|
TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
|
event_type: "codex_app_mentioned",
|
|
event_params,
|
|
})
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) {
|
|
let TrackAppUsedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
app,
|
|
} = job;
|
|
let event_params = codex_app_metadata(&tracking, app);
|
|
let events = vec![TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
|
event_type: "codex_app_used",
|
|
event_params,
|
|
})];
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_turn_event(auth_manager: &AuthManager, base_url: &str, job: TrackTurnEventJob) {
|
|
let TrackTurnEventJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
turn_event,
|
|
} = job;
|
|
let events = vec![TrackEventRequest::TurnEvent(CodexTurnEventRequest {
|
|
event_type: "codex_turn_event",
|
|
event_params: codex_turn_event_params(&tracking, turn_event),
|
|
})];
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_plugin_used(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginUsedJob,
|
|
) {
|
|
let TrackPluginUsedJob {
|
|
analytics_enabled,
|
|
tracking,
|
|
plugin,
|
|
} = job;
|
|
let events = vec![TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
|
|
event_type: "codex_plugin_used",
|
|
event_params: codex_plugin_used_metadata(&tracking, plugin),
|
|
})];
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
async fn send_track_plugin_installed(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginManagementJob,
|
|
) {
|
|
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await;
|
|
}
|
|
|
|
async fn send_track_plugin_uninstalled(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginManagementJob,
|
|
) {
|
|
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled")
|
|
.await;
|
|
}
|
|
|
|
async fn send_track_plugin_enabled(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginManagementJob,
|
|
) {
|
|
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await;
|
|
}
|
|
|
|
async fn send_track_plugin_disabled(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginManagementJob,
|
|
) {
|
|
send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await;
|
|
}
|
|
|
|
async fn send_track_plugin_management_event(
|
|
auth_manager: &AuthManager,
|
|
base_url: &str,
|
|
job: TrackPluginManagementJob,
|
|
event_type: &'static str,
|
|
) {
|
|
let TrackPluginManagementJob {
|
|
analytics_enabled,
|
|
plugin,
|
|
} = job;
|
|
let event_params = codex_plugin_metadata(plugin);
|
|
let event = CodexPluginEventRequest {
|
|
event_type,
|
|
event_params,
|
|
};
|
|
let events = vec![match event_type {
|
|
"codex_plugin_installed" => TrackEventRequest::PluginInstalled(event),
|
|
"codex_plugin_uninstalled" => TrackEventRequest::PluginUninstalled(event),
|
|
"codex_plugin_enabled" => TrackEventRequest::PluginEnabled(event),
|
|
"codex_plugin_disabled" => TrackEventRequest::PluginDisabled(event),
|
|
_ => unreachable!("unknown plugin management event type"),
|
|
}];
|
|
|
|
send_track_events(auth_manager, analytics_enabled, base_url, events).await;
|
|
}
|
|
|
|
fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata {
|
|
CodexAppMetadata {
|
|
connector_id: app.connector_id,
|
|
thread_id: Some(tracking.thread_id.clone()),
|
|
turn_id: Some(tracking.turn_id.clone()),
|
|
app_name: app.app_name,
|
|
product_client_id: Some(originator().value),
|
|
invoke_type: app.invocation_type,
|
|
model_slug: Some(tracking.model_slug.clone()),
|
|
}
|
|
}
|
|
|
|
fn codex_turn_event_params(
|
|
tracking: &TrackEventsContext,
|
|
turn_event: CodexTurnEvent,
|
|
) -> CodexTurnEventParams {
|
|
CodexTurnEventParams {
|
|
thread_id: tracking.thread_id.clone(),
|
|
turn_id: tracking.turn_id.clone(),
|
|
product_client_id: Some(originator().value),
|
|
model: Some(tracking.model_slug.clone()),
|
|
submission_type: turn_event.submission_type,
|
|
model_provider: turn_event.model_provider,
|
|
sandbox_policy: Some(sandbox_policy_mode(&turn_event.sandbox_policy)),
|
|
reasoning_effort: turn_event.reasoning_effort.map(|value| value.to_string()),
|
|
reasoning_summary: reasoning_summary_mode(turn_event.reasoning_summary),
|
|
service_tier: turn_event
|
|
.service_tier
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "default".to_string()),
|
|
approval_policy: turn_event.approval_policy.to_string(),
|
|
approvals_reviewer: turn_event.approvals_reviewer.to_string(),
|
|
sandbox_network_access: turn_event.sandbox_network_access,
|
|
collaboration_mode: Some(collaboration_mode_mode(turn_event.collaboration_mode)),
|
|
personality: personality_mode(turn_event.personality),
|
|
num_input_images: turn_event.num_input_images,
|
|
is_first_turn: turn_event.is_first_turn,
|
|
}
|
|
}
|
|
|
|
fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str {
|
|
match sandbox_policy {
|
|
SandboxPolicy::DangerFullAccess => "full_access",
|
|
SandboxPolicy::ReadOnly { .. } => "read_only",
|
|
SandboxPolicy::WorkspaceWrite { .. } => "workspace_write",
|
|
SandboxPolicy::ExternalSandbox { .. } => "external_sandbox",
|
|
}
|
|
}
|
|
|
|
fn collaboration_mode_mode(mode: ModeKind) -> &'static str {
|
|
match mode {
|
|
ModeKind::Plan => "plan",
|
|
ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default",
|
|
}
|
|
}
|
|
|
|
fn reasoning_summary_mode(summary: Option<ReasoningSummary>) -> Option<String> {
|
|
match summary {
|
|
Some(ReasoningSummary::None) | None => None,
|
|
Some(summary) => Some(summary.to_string()),
|
|
}
|
|
}
|
|
|
|
fn personality_mode(personality: Option<Personality>) -> Option<String> {
|
|
match personality {
|
|
Some(Personality::None) | None => None,
|
|
Some(personality) => Some(personality.to_string()),
|
|
}
|
|
}
|
|
|
|
fn codex_thread_initialized_event_params(
|
|
thread_event: CodexThreadInitializedEvent,
|
|
) -> CodexThreadInitializedEventParams {
|
|
CodexThreadInitializedEventParams {
|
|
thread_id: thread_event.thread_id,
|
|
product_client_id: originator().value,
|
|
model: thread_event.model,
|
|
model_provider: thread_event.model_provider,
|
|
reasoning_effort: thread_event.reasoning_effort.map(|value| value.to_string()),
|
|
reasoning_summary: thread_event
|
|
.reasoning_summary
|
|
.map(|value| value.to_string()),
|
|
service_tier: thread_event
|
|
.service_tier
|
|
.map(|value| value.to_string())
|
|
.unwrap_or_else(|| "default".to_string()),
|
|
approval_policy: thread_event.approval_policy.to_string(),
|
|
approvals_reviewer: thread_event.approvals_reviewer.to_string(),
|
|
sandbox_policy: sandbox_policy_mode(&thread_event.sandbox_policy),
|
|
sandbox_network_access: thread_event.sandbox_network_access,
|
|
collaboration_mode: collaboration_mode_mode(thread_event.collaboration_mode),
|
|
personality: thread_event.personality.map(|value| value.to_string()),
|
|
ephemeral: thread_event.ephemeral,
|
|
session_source: session_source_name(&thread_event.session_source),
|
|
initialization_mode: thread_event.initialization_mode,
|
|
subagent_source: thread_event.subagent_source.map(subagent_source_name),
|
|
parent_thread_id: thread_event.parent_thread_id,
|
|
created_at: thread_event.created_at,
|
|
}
|
|
}
|
|
|
|
fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata {
|
|
let capability_summary = plugin.capability_summary;
|
|
CodexPluginMetadata {
|
|
plugin_id: Some(plugin.plugin_id.as_key()),
|
|
plugin_name: Some(plugin.plugin_id.plugin_name),
|
|
marketplace_name: Some(plugin.plugin_id.marketplace_name),
|
|
has_skills: capability_summary
|
|
.as_ref()
|
|
.map(|summary| summary.has_skills),
|
|
mcp_server_count: capability_summary
|
|
.as_ref()
|
|
.map(|summary| summary.mcp_server_names.len()),
|
|
connector_ids: capability_summary.map(|summary| {
|
|
summary
|
|
.app_connector_ids
|
|
.into_iter()
|
|
.map(|connector_id| connector_id.0)
|
|
.collect()
|
|
}),
|
|
product_client_id: Some(originator().value),
|
|
}
|
|
}
|
|
|
|
fn codex_plugin_used_metadata(
|
|
tracking: &TrackEventsContext,
|
|
plugin: PluginTelemetryMetadata,
|
|
) -> CodexPluginUsedMetadata {
|
|
CodexPluginUsedMetadata {
|
|
plugin: codex_plugin_metadata(plugin),
|
|
thread_id: Some(tracking.thread_id.clone()),
|
|
turn_id: Some(tracking.turn_id.clone()),
|
|
model_slug: Some(tracking.model_slug.clone()),
|
|
}
|
|
}
|
|
|
|
fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str {
|
|
match sandbox_policy {
|
|
SandboxPolicy::DangerFullAccess => "full_access",
|
|
SandboxPolicy::ReadOnly { .. } => "read_only",
|
|
SandboxPolicy::WorkspaceWrite { .. } => "workspace_write",
|
|
SandboxPolicy::ExternalSandbox { .. } => "external_sandbox",
|
|
}
|
|
}
|
|
|
|
fn collaboration_mode_mode(mode: ModeKind) -> &'static str {
|
|
match mode {
|
|
ModeKind::Plan => "plan",
|
|
ModeKind::Default => "default",
|
|
ModeKind::PairProgramming => "pair_programming",
|
|
ModeKind::Execute => "execute",
|
|
}
|
|
}
|
|
|
|
fn session_source_name(session_source: &SessionSource) -> Option<&'static str> {
|
|
match session_source {
|
|
SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"),
|
|
SessionSource::SubAgent(_) => Some("subagent"),
|
|
SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None,
|
|
}
|
|
}
|
|
|
|
fn subagent_source_name(subagent_source: SubAgentSource) -> String {
|
|
match subagent_source {
|
|
SubAgentSource::Review => "review".to_string(),
|
|
SubAgentSource::Compact => "compact".to_string(),
|
|
SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(),
|
|
SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(),
|
|
SubAgentSource::Other(other) => other,
|
|
}
|
|
}
|
|
|
|
async fn send_track_events(
|
|
auth_manager: &AuthManager,
|
|
analytics_enabled: Option<bool>,
|
|
base_url: &str,
|
|
events: Vec<TrackEventRequest>,
|
|
) {
|
|
if analytics_enabled == Some(false) {
|
|
return;
|
|
}
|
|
if events.is_empty() {
|
|
return;
|
|
}
|
|
let Some(auth) = auth_manager.auth().await else {
|
|
return;
|
|
};
|
|
if !auth.is_chatgpt_auth() {
|
|
return;
|
|
}
|
|
let access_token = match auth.get_token() {
|
|
Ok(token) => token,
|
|
Err(_) => return,
|
|
};
|
|
let Some(account_id) = auth.get_account_id() else {
|
|
return;
|
|
};
|
|
|
|
let base_url = base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/codex/analytics-events/events");
|
|
let payload = TrackEventsRequest { events };
|
|
|
|
let response = create_client()
|
|
.post(&url)
|
|
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
|
.bearer_auth(&access_token)
|
|
.header("chatgpt-account-id", &account_id)
|
|
.header("Content-Type", "application/json")
|
|
.json(&payload)
|
|
.send()
|
|
.await;
|
|
|
|
match response {
|
|
Ok(response) if response.status().is_success() => {}
|
|
Ok(response) => {
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::warn!("events failed with status {status}: {body}");
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!("failed to send events request: {err}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn skill_id_for_local_skill(
|
|
repo_url: Option<&str>,
|
|
repo_root: Option<&Path>,
|
|
skill_path: &Path,
|
|
skill_name: &str,
|
|
) -> String {
|
|
let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path);
|
|
let prefix = if let Some(url) = repo_url {
|
|
format!("repo_{url}")
|
|
} else {
|
|
"personal".to_string()
|
|
};
|
|
let raw_id = format!("{prefix}_{path}_{skill_name}");
|
|
let mut hasher = Sha1::new();
|
|
hasher.update(raw_id.as_bytes());
|
|
format!("{:x}", hasher.finalize())
|
|
}
|
|
|
|
/// Returns a normalized path for skill ID construction.
|
|
///
|
|
/// - Repo-scoped skills use a path relative to the repo root.
|
|
/// - User/admin/system skills use an absolute path.
|
|
fn normalize_path_for_skill_id(
|
|
repo_url: Option<&str>,
|
|
repo_root: Option<&Path>,
|
|
skill_path: &Path,
|
|
) -> String {
|
|
let resolved_path =
|
|
std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf());
|
|
match (repo_url, repo_root) {
|
|
(Some(_), Some(root)) => {
|
|
let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
|
resolved_path
|
|
.strip_prefix(&resolved_root)
|
|
.unwrap_or(resolved_path.as_path())
|
|
.to_string_lossy()
|
|
.replace('\\', "/")
|
|
}
|
|
_ => resolved_path.to_string_lossy().replace('\\', "/"),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "analytics_client_tests.rs"]
|
|
mod tests;
|