Separate local and remote plugin analytics IDs (#29495)

## Why

Plugin analytics overloaded `plugin_id`: most events used the Codex
`<plugin>@<marketplace>` identity, while remote install events used the
backend plugin ID. That makes the same field change meaning across event
types and complicates downstream identity resolution.

This change makes the contract unambiguous:

- `plugin_id`: the local Codex `<plugin>@<marketplace>` identity, when
resolved
- `remote_plugin_id`: the backend plugin identity, when available

For a remote install failure that happens before plugin details resolve,
`plugin_id` is `null` and `remote_plugin_id` remains populated.

## What changed

All six plugin analytics events use the same identity contract:

- `codex_plugin_installed`
- `codex_plugin_install_failed`
- `codex_plugin_uninstalled`
- `codex_plugin_enabled`
- `codex_plugin_disabled`
- `codex_plugin_used`

Remote identity is resolved from the current installed-plugin snapshot
first, with persisted install metadata as fallback. The telemetry
metadata type keeps local identity optional for failures that occur
before remote details are available.

The app-server test client's manual analytics smokes now find remote
mutation events through `remote_plugin_id` and validate that `plugin_id`
remains local.

## Remote uninstall

Resolve and capture telemetry metadata before removing the local plugin
cache, then emit `codex_plugin_uninstalled` after the backend confirms
success. The event is also emitted when backend uninstall succeeds but
local cache cleanup reports `CacheRemove`.

If a concurrent remote-cache refresh removes the local bundle before
telemetry capture, the already-fetched remote plugin detail supplies
fallback capability metadata.

## Validation

- `just test -p codex-analytics` — 82 passed
- `just test -p codex-core-plugins` — 271 passed
- `just test -p codex-app-server-test-client` — 5 passed
- `just test -p codex-plugin` — 3 passed
- `just test -p codex-app-server plugin_install` — 37 passed
- `just test -p codex-app-server plugin_uninstall` — 10 passed

The production app-server install/uninstall flow was also exercised
against `plugins~Plugin_f1b845ac33888191ac156169c58733c2`
(`build-ios-apps@openai-curated-remote`), and the plugin's original
uninstalled state was restored.
This commit is contained in:
jameswt-oai
2026-06-23 12:27:14 -07:00
committed by GitHub
Unverified
parent c5a9a95ab6
commit ff50b47dce
15 changed files with 427 additions and 91 deletions
@@ -23,6 +23,7 @@ use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::should_retry_without_scopes;
use codex_plugin::PluginId;
use codex_plugin::PluginTelemetryMetadata;
use codex_rmcp_client::perform_oauth_login_silent;
#[derive(Clone)]
@@ -1528,6 +1529,7 @@ impl PluginRequestProcessor {
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&remote_marketplace_name,
/*plugin_id*/ None,
error_type,
err.to_string(),
);
@@ -1538,6 +1540,12 @@ impl PluginRequestProcessor {
})?;
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
let remote_plugin_name = remote_detail.summary.name.clone();
let resolved_plugin_id = PluginId::parse(&remote_detail.summary.id).map_err(|err| {
internal_error(format!(
"invalid resolved plugin id `{}`: {err}",
remote_detail.summary.id
))
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is disabled by admin"
@@ -1569,6 +1577,7 @@ impl PluginRequestProcessor {
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
err.to_string(),
);
@@ -1585,6 +1594,7 @@ impl PluginRequestProcessor {
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&resolved_plugin_id),
error_type,
err.to_string(),
);
@@ -1606,6 +1616,7 @@ impl PluginRequestProcessor {
self.track_plugin_install_failed_for_remote_plugin(
&remote_plugin_id,
&actual_remote_marketplace_name,
Some(&result.plugin_id),
error_type,
err.to_string(),
);
@@ -1702,6 +1713,7 @@ impl PluginRequestProcessor {
&self,
remote_plugin_id: &str,
marketplace_name: &str,
plugin_id: Option<&PluginId>,
error_type: &'static str,
error_message: String,
) {
@@ -1712,16 +1724,17 @@ impl PluginRequestProcessor {
error = %error_message,
"remote plugin install failed"
);
// The remote id is reported separately; this local name only satisfies
// PluginId validation before remote details are available.
let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string())
else {
return;
let plugin = if let Some(plugin_id) = plugin_id {
self.thread_manager
.plugins_manager()
.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id)
} else {
PluginTelemetryMetadata {
plugin_id: None,
remote_plugin_id: Some(remote_plugin_id.to_string()),
capability_summary: None,
}
};
let plugin = self
.thread_manager
.plugins_manager()
.telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id);
self.analytics_events_client
.track_plugin_install_failed(plugin, error_type.to_string());
}
@@ -1980,11 +1993,31 @@ impl PluginRequestProcessor {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target(
&remote_plugin_service_config,
auth.as_ref(),
&plugin_id,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "resolve remote plugin before uninstall")
})?;
let plugins_manager = self.thread_manager.plugins_manager();
let mut plugin_telemetry = plugins_manager
.telemetry_metadata_for_installed_plugin_with_remote_id(
&uninstall_target.plugin_id,
&uninstall_target.remote_plugin_id,
)
.await;
if plugin_telemetry.capability_summary.is_none() {
plugin_telemetry.capability_summary =
Some(uninstall_target.fallback_capability_summary.clone());
}
let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.to_path_buf(),
&plugin_id,
uninstall_target,
)
.await;
@@ -1992,7 +2025,8 @@ impl PluginRequestProcessor {
&uninstall_result,
Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_))
) {
let plugins_manager = self.thread_manager.plugins_manager();
self.analytics_events_client
.track_plugin_uninstalled(plugin_telemetry);
if plugins_manager.clear_remote_installed_plugins_cache() {
self.on_effective_plugins_changed();
}
@@ -538,12 +538,10 @@ async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Res
payload["events"][0]["event_type"],
"codex_plugin_install_failed"
);
assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID);
assert_eq!(event_params["plugin_name"], "unknown");
assert_eq!(
event_params["marketplace_name"],
"caller-marketplace-is-ignored"
);
assert_eq!(event_params["plugin_id"], json!(null));
assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID);
assert_eq!(event_params["plugin_name"], json!(null));
assert_eq!(event_params["marketplace_name"], json!(null));
assert_eq!(
event_params["error_type"],
"remote_catalog_unexpected_status"
@@ -883,6 +881,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": "sample-plugin@debug",
"remote_plugin_id": null,
"plugin_name": "sample-plugin",
"marketplace_name": "debug",
"has_skills": false,
@@ -946,6 +945,7 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> {
"codex_plugin_install_failed"
);
assert_eq!(event_params["plugin_id"], "sample-plugin@debug");
assert_eq!(event_params["remote_plugin_id"], json!(null));
assert_eq!(event_params["plugin_name"], "sample-plugin");
assert_eq!(event_params["marketplace_name"], "debug");
assert_eq!(event_params["has_skills"], json!(null));
@@ -995,7 +995,8 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
"events": [{
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": REMOTE_PLUGIN_ID,
"plugin_id": "linear@openai-curated-remote",
"remote_plugin_id": REMOTE_PLUGIN_ID,
"plugin_name": "linear",
"marketplace_name": "openai-curated-remote",
"has_skills": true,
@@ -1072,7 +1073,8 @@ async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_la
payload["events"][0]["event_type"],
"codex_plugin_install_failed"
);
assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID);
assert_eq!(event_params["plugin_id"], "linear@openai-curated-remote");
assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID);
assert_eq!(event_params["marketplace_name"], "openai-curated-remote");
assert_eq!(event_params["error_type"], "remote_bundle_download_status");
assert!(
@@ -139,6 +139,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
"event_type": "codex_plugin_uninstalled",
"event_params": {
"plugin_id": "sample-plugin@debug",
"remote_plugin_id": null,
"plugin_name": "sample-plugin",
"marketplace_name": "debug",
"has_skills": false,
@@ -216,6 +217,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/codex/analytics-events/events"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
.mount(&server)
.await;
let remote_plugin_cache_root = codex_home
.path()
@@ -225,6 +231,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
r#"{"name":"linear","version":"1.0.0"}"#,
)?;
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/skills/plan-work"))?;
std::fs::write(
remote_plugin_cache_root.join("1.0.0/skills/plan-work/SKILL.md"),
"---\nname: plan-work\ndescription: Plan work\n---\n",
)?;
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
));
@@ -233,6 +244,10 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
// Simulate a background remote-cache refresh removing the local bundle
// before the uninstall request captures its telemetry metadata.
std::fs::remove_dir_all(remote_plugin_cache_root.join("1.0.0"))?;
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: REMOTE_PLUGIN_ID.to_string(),
@@ -255,6 +270,25 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
.await?;
assert!(!remote_plugin_cache_root.exists());
assert!(!legacy_remote_plugin_cache_root.exists());
let payload = wait_for_plugin_analytics_payload(&server).await?;
assert_eq!(
payload,
json!({
"events": [{
"event_type": "codex_plugin_uninstalled",
"event_params": {
"plugin_id": "linear@openai-curated-remote",
"remote_plugin_id": REMOTE_PLUGIN_ID,
"plugin_name": "linear",
"marketplace_name": "openai-curated-remote",
"has_skills": true,
"mcp_server_count": 0,
"connector_ids": [],
"product_client_id": DEFAULT_CLIENT_NAME,
}
}]
})
);
Ok(())
}
@@ -638,7 +672,11 @@ async fn mount_remote_plugin_detail_with_name(
"interface": {{
"short_description": "Plan and track work"
}},
"skills": []
"skills": [{{
"name": "plan-work",
"description": "Plan work",
"interface": null
}}]
}}
}}"#
);
@@ -652,6 +690,29 @@ async fn mount_remote_plugin_detail_with_name(
.await;
}
async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result<serde_json::Value> {
timeout(DEFAULT_TIMEOUT, async {
loop {
let Some(requests) = server.received_requests().await else {
tokio::time::sleep(Duration::from_millis(25)).await;
continue;
};
if let Some(request) = requests.iter().find(|request| {
request.method == "POST"
&& request
.url
.path()
.ends_with("/codex/analytics-events/events")
}) {
return serde_json::from_slice(&request.body)
.map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"));
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
})
.await?
}
async fn wait_for_remote_plugin_request_count(
server: &MockServer,
method_name: &str,