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 authored and GitHub committed 2026-06-23 12:27:14 -07:00
1 parent c5a9a95ab6
commit ff50b47dce
15 files changed
+427 -91

No files matched your search

@@ -3027,6 +3027,7 @@ fn plugin_used_event_serializes_expected_shape() {
"event_type": "codex_plugin_used",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": null,
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
@@ -3057,6 +3058,7 @@ fn plugin_management_event_serializes_expected_shape() {
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": null,
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
@@ -3086,6 +3088,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
"event_type": "codex_plugin_install_failed",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": null,
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
@@ -3099,7 +3102,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
}
#[test]
fn plugin_management_event_can_use_remote_plugin_id_override() {
fn plugin_management_event_keeps_plugin_id_local_when_remote_id_exists() {
let mut plugin = sample_plugin_metadata();
plugin.remote_plugin_id = Some("plugins~Plugin_remote".to_string());
let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest {
@@ -3110,11 +3113,21 @@ fn plugin_management_event_can_use_remote_plugin_id_override() {
let payload = serde_json::to_value(&event).expect("serialize plugin installed event");
assert_eq!(
payload["event_params"]["plugin_id"],
"plugins~Plugin_remote"
payload,
json!({
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": "plugins~Plugin_remote",
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
"mcp_server_count": 2,
"connector_ids": ["calendar", "drive"],
"product_client_id": originator().value
}
})
);
assert_eq!(payload["event_params"]["plugin_name"], "sample");
assert_eq!(payload["event_params"]["marketplace_name"], "test");
}
#[test]
@@ -3454,6 +3467,7 @@ async fn reducer_ingests_plugin_state_changed_fact() {
"event_type": "codex_plugin_disabled",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": null,
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
@@ -3489,6 +3503,7 @@ async fn reducer_ingests_plugin_install_failed_fact() {
"event_type": "codex_plugin_install_failed",
"event_params": {
"plugin_id": "sample@test",
"remote_plugin_id": null,
"plugin_name": "sample",
"marketplace_name": "test",
"has_skills": true,
@@ -3506,7 +3521,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
let plugin = PluginTelemetryMetadata {
plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"),
plugin_id: None,
remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()),
capability_summary: None,
};
@@ -3529,9 +3544,10 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() {
json!([{
"event_type": "codex_plugin_install_failed",
"event_params": {
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
"plugin_name": "unknown",
"marketplace_name": "openai-curated-remote",
"plugin_id": null,
"remote_plugin_id": "plugins~Plugin_00000000000000000000000000000000",
"plugin_name": null,
"marketplace_name": null,
"has_skills": null,
"mcp_server_count": null,
"connector_ids": null,
@@ -4570,7 +4586,7 @@ async fn turn_completed_without_started_notification_emits_null_started_at() {
fn sample_plugin_metadata() -> PluginTelemetryMetadata {
PluginTelemetryMetadata {
plugin_id: PluginId::parse("sample@test").expect("valid plugin id"),
plugin_id: Some(PluginId::parse("sample@test").expect("valid plugin id")),
remote_plugin_id: None,
capability_summary: Some(PluginCapabilitySummary {
config_name: "sample@test".to_string(),