Files
codex/codex-rs/app-server/src/request_processors/feedback_processor.rs
pakrym-oai acac786d91 [codex] add account id to feedback uploads (#21498)
## Why

Feedback uploads already carry auth-derived context like
`chatgpt_user_id`, but they do not include the authenticated
workspace/account id. Adding `account_id` makes feedback triage easier
when a user can operate across multiple ChatGPT workspaces.

## What changed

- emit auth-derived `account_id` into feedback tags in `app-server`
before the feedback snapshot is uploaded
- preserve that tag through `codex-feedback` upload tag assembly
alongside the existing merge behavior for other tags
- extend `codex-feedback` coverage to assert that snapshot-derived
`account_id` is present in uploaded tags

## Verification

- `cargo test -p codex-feedback
upload_tags_include_client_tags_and_preserve_reserved_fields`
- `cargo test -p codex-app-server --lib feedback_processor`
2026-05-07 08:45:16 -07:00

253 lines
9.2 KiB
Rust

use super::*;
#[derive(Clone)]
pub(crate) struct FeedbackRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
state_db: Option<StateDbHandle>,
}
impl FeedbackRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
state_db: Option<StateDbHandle>,
) -> Self {
Self {
auth_manager,
thread_manager,
config,
feedback,
log_db,
state_db,
}
}
pub(crate) async fn feedback_upload(
&self,
params: FeedbackUploadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.upload_feedback_response(params)
.await
.map(|response| Some(response.into()))
}
async fn upload_feedback_response(
&self,
params: FeedbackUploadParams,
) -> Result<FeedbackUploadResponse, JSONRPCErrorError> {
if !self.config.feedback_enabled {
return Err(invalid_request(
"sending feedback is disabled by configuration",
));
}
let FeedbackUploadParams {
classification,
reason,
thread_id,
include_logs,
extra_log_files,
tags,
} = params;
let conversation_id = match thread_id.as_deref() {
Some(thread_id) => match ThreadId::from_string(thread_id) {
Ok(conversation_id) => Some(conversation_id),
Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))),
},
None => None,
};
if let Some(chatgpt_user_id) = self
.auth_manager
.auth_cached()
.and_then(|auth| auth.get_chatgpt_user_id())
{
tracing::info!(target: "feedback_tags", chatgpt_user_id);
}
if let Some(account_id) = self
.auth_manager
.auth_cached()
.and_then(|auth| auth.get_account_id())
{
tracing::info!(target: "feedback_tags", account_id);
}
let snapshot = self.feedback.snapshot(conversation_id);
let thread_id = snapshot.thread_id.clone();
let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs {
if let Some(log_db) = self.log_db.as_ref() {
log_db.flush().await;
}
let state_db_ctx = self.state_db.clone();
let feedback_thread_ids = match conversation_id {
Some(conversation_id) => match self
.thread_manager
.list_agent_subtree_thread_ids(conversation_id)
.await
{
Ok(thread_ids) => thread_ids,
Err(err) => {
warn!(
"failed to list feedback subtree for thread_id={conversation_id}: {err}"
);
let mut thread_ids = vec![conversation_id];
if let Some(state_db_ctx) = state_db_ctx.as_ref() {
for status in [
codex_state::DirectionalThreadSpawnEdgeStatus::Open,
codex_state::DirectionalThreadSpawnEdgeStatus::Closed,
] {
match state_db_ctx
.list_thread_spawn_descendants_with_status(
conversation_id,
status,
)
.await
{
Ok(descendant_ids) => thread_ids.extend(descendant_ids),
Err(err) => warn!(
"failed to list persisted feedback subtree for thread_id={conversation_id}: {err}"
),
}
}
}
thread_ids
}
},
None => Vec::new(),
};
let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref()
&& !feedback_thread_ids.is_empty()
{
let thread_id_texts = feedback_thread_ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
let thread_id_refs = thread_id_texts
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match state_db_ctx
.query_feedback_logs_for_threads(&thread_id_refs)
.await
{
Ok(logs) if logs.is_empty() => None,
Ok(logs) => Some(logs),
Err(err) => {
let thread_ids = thread_id_texts.join(", ");
warn!(
"failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}"
);
None
}
}
} else {
None
};
(feedback_thread_ids, sqlite_feedback_logs, state_db_ctx)
} else {
(Vec::new(), None, None)
};
let mut attachment_paths = Vec::new();
let mut seen_attachment_paths = HashSet::new();
if include_logs {
for feedback_thread_id in &feedback_thread_ids {
let Some(rollout_path) = self
.resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref())
.await
else {
continue;
};
if seen_attachment_paths.insert(rollout_path.clone()) {
attachment_paths.push(FeedbackAttachmentPath {
path: rollout_path,
attachment_filename_override: None,
});
}
}
if let Some(conversation_id) = conversation_id
&& let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
&& let Some(guardian_rollout_path) =
conversation.guardian_trunk_rollout_path().await
&& seen_attachment_paths.insert(guardian_rollout_path.clone())
{
attachment_paths.push(FeedbackAttachmentPath {
path: guardian_rollout_path,
attachment_filename_override: Some(auto_review_rollout_filename(
conversation_id,
)),
});
}
}
if let Some(extra_log_files) = extra_log_files {
for extra_log_file in extra_log_files {
if seen_attachment_paths.insert(extra_log_file.clone()) {
attachment_paths.push(FeedbackAttachmentPath {
path: extra_log_file,
attachment_filename_override: None,
});
}
}
}
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
snapshot.upload_feedback(FeedbackUploadOptions {
classification: &classification,
reason: reason.as_deref(),
tags: tags.as_ref(),
include_logs,
extra_attachment_paths: &attachment_paths,
session_source: Some(session_source),
logs_override: sqlite_feedback_logs,
})
})
.await;
let upload_result = match upload_result {
Ok(result) => result,
Err(join_err) => {
return Err(internal_error(format!(
"failed to upload feedback: {join_err}"
)));
}
};
upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?;
Ok(FeedbackUploadResponse { thread_id })
}
async fn resolve_rollout_path(
&self,
conversation_id: ThreadId,
state_db_ctx: Option<&StateDbHandle>,
) -> Option<PathBuf> {
if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
&& let Some(rollout_path) = conversation.rollout_path()
{
return Some(rollout_path);
}
let state_db_ctx = state_db_ctx?;
state_db_ctx
.find_rollout_path_by_id(conversation_id, /*archived_only*/ None)
.await
.unwrap_or_else(|err| {
warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}");
None
})
}
}
fn auto_review_rollout_filename(thread_id: ThreadId) -> String {
format!("auto-review-rollout-{thread_id}.jsonl")
}