Track dictation usage from accepted user inputs (#61215)

## What changed

Capture `codex_app_used_dictation` as per-input metadata, with patches taking precedence over replacements, and remove the key from metadata forwarded to Responses. Record attribution when user-origin input is accepted into turn history.

Add `used_dictation` to turn analytics: report `true` if any accepted input used dictation, `false` only when every accepted input has known non-dictation attribution, and omit the field otherwise.

## Testing

Add coverage for per-input extraction, patch precedence, and turn-level aggregation. Extend the app-server steering test to verify that dictation attribution reaches analytics while the metadata key is excluded from Responses requests.

GitOrigin-RevId: 0a55687a7dd61f00e9b188f89810e52bd941490b
This commit is contained in:
Chris Dong
2026-11-13 00:52:01 +00:00
committed by copyberry
parent 83a23c0936
commit 006911a313
10 changed files with 159 additions and 12 deletions
+10
View File
@@ -745,6 +745,16 @@ impl AnalyticsEventsClient {
)));
}
/// Record the provenance of one user input after it is added to turn history.
pub fn track_accepted_user_input(&self, turn_id: &str, used_dictation: Option<bool>) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::AcceptedUserInput {
turn_id: turn_id.to_owned(),
used_dictation,
},
));
}
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
self.record_fact(AnalyticsFact::Custom(
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
+3
View File
@@ -1174,6 +1174,9 @@ pub(crate) struct CodexTurnEventParams {
/// The server-selected window responsible for a usage limit, when known.
pub(crate) usage_limit_window_minutes: Option<u16>,
pub(crate) steer_count: Option<usize>,
/// True when any accepted input used dictation. False requires known attribution for every input.
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) used_dictation: Option<bool>,
pub(crate) total_tool_call_count: Option<usize>,
pub(crate) shell_command_count: Option<usize>,
pub(crate) file_change_count: Option<usize>,
+4
View File
@@ -627,6 +627,10 @@ pub(crate) enum CustomAnalyticsFact {
TurnTokenUsage(Box<TurnTokenUsageFact>),
TurnProfile(Box<TurnProfileFact>),
TurnCodexError(Box<TurnCodexErrorFact>),
AcceptedUserInput {
turn_id: String,
used_dictation: Option<bool>,
},
ImagePreparation(Box<ImagePreparationFact>),
SkillInvoked(SkillInvokedInput),
AppMentioned(AppMentionedInput),
+26
View File
@@ -476,6 +476,14 @@ struct CompletedTurnState {
duration_ms: Option<u64>,
}
#[derive(Clone, Copy, Default)]
enum TurnDictation {
#[default]
NoUserInput,
Unknown,
Known(bool),
}
#[derive(Default)]
struct TurnState {
connection_id: Option<u64>,
@@ -492,6 +500,7 @@ struct TurnState {
codex_error: Option<TurnCodexError>,
latest_diff: Option<String>,
steer_count: usize,
dictation: TurnDictation,
tool_counts: TurnToolCounts,
completed_cua_tool_item_ids: HashSet<String>,
resource_skill_invocations: HashSet<String>,
@@ -817,6 +826,19 @@ impl AnalyticsReducer {
CustomAnalyticsFact::TurnCodexError(input) => {
self.ingest_turn_codex_error(*input);
}
CustomAnalyticsFact::AcceptedUserInput {
turn_id,
used_dictation,
} => {
let state = self.turn_state(turn_id);
state.dictation = match (state.dictation, used_dictation) {
(TurnDictation::Known(true), _) | (_, Some(true)) => {
TurnDictation::Known(true)
}
(TurnDictation::Unknown, _) | (_, None) => TurnDictation::Unknown,
(_, Some(false)) => TurnDictation::Known(false),
};
}
CustomAnalyticsFact::ImagePreparation(input) => {
self.ingest_image_preparation(*input);
}
@@ -4031,6 +4053,10 @@ fn codex_turn_event_params(
codex_error_http_status_code: codex_error.and_then(|error| error.http_status_code),
usage_limit_window_minutes: codex_error.and_then(|error| error.usage_limit_window_minutes),
steer_count: Some(turn_state.steer_count),
used_dictation: match turn_state.dictation {
TurnDictation::Known(value) => Some(value),
TurnDictation::NoUserInput | TurnDictation::Unknown => None,
},
total_tool_call_count: Some(turn_state.tool_counts.total),
shell_command_count: Some(turn_state.tool_counts.shell_command),
file_change_count: Some(turn_state.tool_counts.file_change),
@@ -154,6 +154,7 @@ fn turn_event_serializes_expected_shape() {
codex_error_http_status_code: None,
usage_limit_window_minutes: None,
steer_count: Some(0),
used_dictation: None,
total_tool_call_count: None,
shell_command_count: None,
file_change_count: None,
@@ -789,6 +790,54 @@ async fn turn_does_not_emit_without_required_prerequisites() {
assert!(out.is_empty());
}
#[tokio::test]
async fn turn_lifecycle_emits_dictation_from_accepted_inputs() {
for (inputs, expected) in [
(&[Some(false)][..], Some(false)),
(&[Some(false), None][..], None),
(&[None, Some(true), Some(false)][..], Some(true)),
] {
let mut reducer = AnalyticsReducer::default();
let mut out = Vec::new();
ingest_turn_prerequisites(
&mut reducer,
&mut out,
/*include_initialize*/ true,
/*include_resolved_config*/ true,
/*include_started*/ true,
/*include_token_usage*/ false,
)
.await;
for &used_dictation in inputs {
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::AcceptedUserInput {
turn_id: "turn-2".to_string(),
used_dictation,
}),
&mut out,
)
.await;
}
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
"thread-2",
"turn-2",
AppServerTurnStatus::Completed,
/*codex_error_info*/ None,
))),
&mut out,
)
.await;
let [event] = out.as_slice() else {
panic!("expected one turn event");
};
let payload = serde_json::to_value(event).expect("serialize turn event");
assert_eq!(payload["event_params"]["used_dictation"], json!(expected));
}
}
#[tokio::test]
async fn turn_lifecycle_emits_failed_turn_event() {
let mut reducer = AnalyticsReducer::default();
@@ -26,6 +26,7 @@ use codex_protocol::protocol::SubAgentSource;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -493,6 +494,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
let start_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-start-123".to_string()),
("codex_app_used_dictation".to_string(), "false".to_string()),
("source".to_string(), "snapshot-source".to_string()),
("start_only".to_string(), "start-value".to_string()),
(
@@ -530,6 +532,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
let steer_metadata = HashMap::from([
("fiber_run_id".to_string(), "fiber-steer-456".to_string()),
("codex_app_used_dictation".to_string(), "true".to_string()),
("origin".to_string(), "gaas".to_string()),
("source".to_string(), "steer-source".to_string()),
(
@@ -579,6 +582,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
assert_eq!(first_metadata["turn_id"].as_str(), Some(turn_id.as_str()));
assert_eq!(first_metadata["turn_trigger"].as_str(), Some("user"));
assert_eq!(first_metadata["source"].as_str(), Some("initial-source"));
assert!(first_metadata.get("codex_app_used_dictation").is_none());
let second_metadata = requests[1]
.header("x-codex-turn-metadata")
@@ -593,6 +597,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
assert_eq!(second_metadata["turn_id"].as_str(), Some(turn_id.as_str()));
assert_eq!(second_metadata["turn_trigger"].as_str(), Some("user"));
assert_eq!(second_metadata["source"].as_str(), Some("steer-source"));
assert!(second_metadata.get("codex_app_used_dictation").is_none());
assert_eq!(
second_metadata["start_only"].as_str(),
patch_steer.then_some("start-value")
@@ -603,12 +608,16 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2(
);
let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?;
let params = &event["event_params"];
assert_eq!(
(
event["event_params"]["turn_trigger"].as_str(),
event["event_params"]["codex_turn_source"].as_str(),
),
(Some("user"), Some("steer-source"))
json!([
params["thread_id"],
params["turn_id"],
params["turn_trigger"],
params["codex_turn_source"],
params["used_dictation"],
]),
json!([thread.id, turn_id, "user", "steer-source", true]),
);
Ok(())
+2
View File
@@ -30,6 +30,8 @@ pub struct UserInputMetadata {
/// Optional host-assigned UUID for this particular Responses user item.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_item_uuid: Option<uuid::Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_dictation: Option<bool>,
#[serde(
default,
skip_serializing_if = "codex_history::UserInputOrigin::is_user"
+5
View File
@@ -6003,6 +6003,11 @@ impl Session {
image_preparations,
)
.await;
if metadata.origin.is_user() {
self.services
.analytics_events_client
.track_accepted_user_input(&turn_context.sub_id, metadata.used_dictation);
}
user_message_item.client_id = client_id;
let turn_item = TurnItem::UserMessage(user_message_item);
self.emit_turn_item_started(turn_context, &turn_item).await;
+19 -7
View File
@@ -380,8 +380,20 @@ pub(super) async fn handle_recovery(
result
}
fn user_input_metadata(request: &TurnInputRequest) -> UserInputMetadata {
fn user_input_metadata(request: &mut TurnInputRequest) -> UserInputMetadata {
const DICTATION_KEY: &str = "codex_app_used_dictation";
// Capture provenance on this input before applying mutable turn metadata.
let metadata = &mut request.responsesapi_client_metadata;
let replacement = metadata
.replacement
.as_mut()
.and_then(|map| map.remove(DICTATION_KEY));
let patch = metadata
.patch
.as_mut()
.and_then(|map| map.remove(DICTATION_KEY));
UserInputMetadata {
used_dictation: patch.or(replacement).and_then(|value| value.parse().ok()),
origin: UserInputOrigin::from_turn_trigger(request.start.turn_trigger.as_deref()),
response_item_uuid: request.user_item_uuid,
extension_data: request.user_input_extension_data.clone(),
@@ -401,10 +413,10 @@ async fn start_or_steer(
return agent::submit(session, request, submission_id).await;
}
let WithTurnExtensionData {
request,
mut request,
turn_extension_init,
} = request;
let input_metadata = user_input_metadata(&request);
let input_metadata = user_input_metadata(&mut request);
let TurnInputRequest {
mut input,
thread_settings,
@@ -528,10 +540,10 @@ async fn start_if_idle(
expected_previous_turn_id: Option<String>,
) -> CodexResult<TurnInputSubmission> {
let WithTurnExtensionData {
request,
mut request,
turn_extension_init,
} = request;
let input_metadata = user_input_metadata(&request);
let input_metadata = user_input_metadata(&mut request);
let TurnInputRequest {
mut input,
thread_settings,
@@ -702,10 +714,10 @@ async fn steer(
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let WithTurnExtensionData {
request,
mut request,
turn_extension_init,
} = request;
let input_metadata = user_input_metadata(&request);
let input_metadata = user_input_metadata(&mut request);
let TurnInputRequest {
mut input,
thread_settings,
@@ -1164,6 +1164,33 @@ async fn steer_preserves_request_origin(
session.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[test]
fn dictation_metadata_is_captured_per_input_and_removed_from_responses() {
use std::collections::HashMap;
let mut request = TurnInputRequest::user_input(Vec::new())
.with_responses_metadata(Some(HashMap::from([
("source".to_owned(), "codex".to_owned()),
("codex_app_used_dictation".to_owned(), "true".to_owned()),
])))
.with_responses_metadata_patch(Some(HashMap::from([(
"codex_app_used_dictation".to_owned(),
"false".to_owned(),
)])));
assert_eq!(
user_input_metadata(&mut request).used_dictation,
Some(false)
);
assert_eq!(
request.responsesapi_client_metadata.replacement,
Some(HashMap::from([("source".to_owned(), "codex".to_owned())]))
);
assert_eq!(
request.responsesapi_client_metadata.patch,
Some(HashMap::new())
);
}
#[tokio::test]
async fn scored_prompt_keeps_its_submission_item_id() {
let (session, turn, events) = make_session_and_context_with_rx().await;