Files
codex/codex-rs/app-server-test-client/src/plugin_analytics_capture.rs
T
jameswt-oaiandGitHub ff50b47dce 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.
2026-06-23 12:27:14 -07:00

108 lines
3.3 KiB
Rust

use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use serde_json::Value;
use std::fs;
use std::io;
use std::path::Path;
pub(super) fn read_events_for_remote_plugin(
path: &Path,
remote_plugin_id: &str,
) -> Result<Vec<Value>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(err).with_context(|| format!("read capture file {}", path.display()));
}
};
let mut matching = Vec::new();
for (index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let payload: Value = serde_json::from_str(line).with_context(|| {
format!(
"parse analytics capture line {} from {}",
index + 1,
path.display()
)
})?;
let events = payload["events"]
.as_array()
.context("analytics capture payload is missing events")?;
matching.extend(
events
.iter()
.filter(|event| event["event_params"]["remote_plugin_id"] == remote_plugin_id)
.cloned(),
);
}
Ok(matching)
}
pub(super) struct PluginEventIdentity<'a> {
pub(super) plugin_id: &'a str,
pub(super) remote_plugin_id: &'a str,
pub(super) plugin_name: &'a str,
pub(super) marketplace_name: &'a str,
}
pub(super) fn validate_mutation_events(
events: Vec<Value>,
expected: PluginEventIdentity<'_>,
) -> Result<Vec<Value>> {
let mut validated = Vec::new();
for event_type in ["codex_plugin_installed", "codex_plugin_uninstalled"] {
let matching = events
.iter()
.filter(|event| event["event_type"] == event_type)
.collect::<Vec<_>>();
let [event] = matching.as_slice() else {
bail!(
"expected exactly one `{event_type}` event for `{}`, found {}",
expected.remote_plugin_id,
matching.len()
);
};
validate_event(event, &expected)?;
validated.push((*event).clone());
}
Ok(validated)
}
fn validate_event(event: &Value, expected: &PluginEventIdentity<'_>) -> Result<()> {
let params = &event["event_params"];
require_string(params, "plugin_id", expected.plugin_id)?;
require_string(params, "remote_plugin_id", expected.remote_plugin_id)?;
require_string(params, "plugin_name", expected.plugin_name)?;
require_string(params, "marketplace_name", expected.marketplace_name)?;
for field in [
"has_skills",
"mcp_server_count",
"connector_ids",
"product_client_id",
] {
if params.get(field).is_none_or(Value::is_null) {
bail!(
"{} event has null or missing `{field}`",
event["event_type"]
);
}
}
Ok(())
}
fn require_string(params: &Value, field: &str, expected: &str) -> Result<()> {
let actual = params.get(field).and_then(Value::as_str);
if actual != Some(expected) {
bail!("expected `{field}` to be `{expected}`, got {actual:?}");
}
Ok(())
}
#[cfg(test)]
#[path = "plugin_analytics_capture_tests.rs"]
mod tests;