Skip to content

Commit 1b5095b

Browse files
authored
Attach more tags to feedback submissions (#8688)
Attach more tags to sentry feedback so it's easier to classify and debug without having to scan through logs. Formatting isn't amazing but it's a start. <img width="1234" height="276" alt="image" src="https://github.com/user-attachments/assets/521a349d-f627-4051-b511-9811cd5cd933" />
1 parent c673e7a commit 1b5095b

File tree

9 files changed

+209
-23
lines changed

9 files changed

+209
-23
lines changed

codex-rs/Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/app-server/src/lib.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,11 @@ use tokio::io::BufReader;
1717
use tokio::io::{self};
1818
use tokio::sync::mpsc;
1919
use toml::Value as TomlValue;
20-
use tracing::Level;
2120
use tracing::debug;
2221
use tracing::error;
2322
use tracing::info;
2423
use tracing_subscriber::EnvFilter;
2524
use tracing_subscriber::Layer;
26-
use tracing_subscriber::filter::Targets;
2725
use tracing_subscriber::layer::SubscriberExt;
2826
use tracing_subscriber::util::SubscriberInitExt;
2927

@@ -103,11 +101,8 @@ pub async fn run_main(
103101
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::FULL)
104102
.with_filter(EnvFilter::from_default_env());
105103

106-
let feedback_layer = tracing_subscriber::fmt::layer()
107-
.with_writer(feedback.make_writer())
108-
.with_ansi(false)
109-
.with_target(false)
110-
.with_filter(Targets::new().with_default(Level::TRACE));
104+
let feedback_layer = feedback.logger_layer();
105+
let feedback_metadata_layer = feedback.metadata_layer();
111106

112107
let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer());
113108

@@ -116,6 +111,7 @@ pub async fn run_main(
116111
let _ = tracing_subscriber::registry()
117112
.with(stderr_fmt)
118113
.with(feedback_layer)
114+
.with(feedback_metadata_layer)
119115
.with(otel_logger_layer)
120116
.with(otel_tracing_layer)
121117
.try_init();

codex-rs/core/src/codex.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ use crate::error::Result as CodexResult;
8888
#[cfg(test)]
8989
use crate::exec::StreamOutput;
9090
use crate::exec_policy::ExecPolicyUpdateError;
91+
use crate::feedback_tags;
9192
use crate::mcp::auth::compute_auth_statuses;
9293
use crate::mcp_connection_manager::McpConnectionManager;
9394
use crate::model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY;
@@ -2539,6 +2540,15 @@ async fn try_run_turn(
25392540
truncation_policy: Some(turn_context.truncation_policy.into()),
25402541
});
25412542

2543+
feedback_tags!(
2544+
model = turn_context.client.get_model(),
2545+
approval_policy = turn_context.approval_policy,
2546+
sandbox_policy = turn_context.sandbox_policy,
2547+
effort = turn_context.client.get_reasoning_effort(),
2548+
auth_mode = sess.services.auth_manager.get_auth_mode(),
2549+
features = sess.features.enabled_features(),
2550+
);
2551+
25422552
sess.persist_rollout_items(&[rollout_item]).await;
25432553
let mut stream = turn_context
25442554
.client

codex-rs/core/src/features.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ impl Features {
255255

256256
features
257257
}
258+
259+
pub fn enabled_features(&self) -> Vec<Feature> {
260+
self.enabled.iter().copied().collect()
261+
}
258262
}
259263

260264
/// Keys accepted in `[features]` tables.

codex-rs/core/src/util.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,31 @@ use tracing::error;
99
const INITIAL_DELAY_MS: u64 = 200;
1010
const BACKOFF_FACTOR: f64 = 2.0;
1111

12+
/// Emit structured feedback metadata as key/value pairs.
13+
///
14+
/// This logs a tracing event with `target: "feedback_tags"`. If
15+
/// `codex_feedback::CodexFeedback::metadata_layer()` is installed, these fields are captured and
16+
/// later attached as tags when feedback is uploaded.
17+
///
18+
/// Values are wrapped with [`tracing::field::DebugValue`], so the expression only needs to
19+
/// implement [`std::fmt::Debug`].
20+
///
21+
/// Example:
22+
///
23+
/// ```rust
24+
/// codex_core::feedback_tags!(model = "gpt-5", cached = true);
25+
/// codex_core::feedback_tags!(provider = provider_id, request_id = request_id);
26+
/// ```
27+
#[macro_export]
28+
macro_rules! feedback_tags {
29+
($( $key:ident = $value:expr ),+ $(,)?) => {
30+
::tracing::info!(
31+
target: "feedback_tags",
32+
$( $key = ::tracing::field::debug(&$value) ),+
33+
);
34+
};
35+
}
36+
1237
pub(crate) fn backoff(attempt: u64) -> Duration {
1338
let exp = BACKOFF_FACTOR.powi(attempt.saturating_sub(1) as i32);
1439
let base = (INITIAL_DELAY_MS as f64 * exp) as u64;
@@ -74,4 +99,12 @@ mod tests {
7499
let message = try_parse_error_message(text);
75100
assert_eq!(message, r#"{"message": "test"}"#);
76101
}
102+
103+
#[test]
104+
fn feedback_tags_macro_compiles() {
105+
#[derive(Debug)]
106+
struct OnlyDebug;
107+
108+
feedback_tags!(model = "gpt-5", cached = true, debug_only = OnlyDebug);
109+
}
77110
}

codex-rs/feedback/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ license.workspace = true
88
anyhow = { workspace = true }
99
codex-protocol = { workspace = true }
1010
sentry = { version = "0.46" }
11+
tracing = { workspace = true }
1112
tracing-subscriber = { workspace = true }
1213

1314
[dev-dependencies]

codex-rs/feedback/src/lib.rs

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
use std::collections::BTreeMap;
12
use std::collections::VecDeque;
3+
use std::collections::btree_map::Entry;
24
use std::fs;
35
use std::io::Write;
46
use std::io::{self};
@@ -11,12 +13,20 @@ use anyhow::Result;
1113
use anyhow::anyhow;
1214
use codex_protocol::ConversationId;
1315
use codex_protocol::protocol::SessionSource;
16+
use tracing::Event;
17+
use tracing::Level;
18+
use tracing::field::Visit;
19+
use tracing_subscriber::Layer;
20+
use tracing_subscriber::filter::Targets;
1421
use tracing_subscriber::fmt::writer::MakeWriter;
22+
use tracing_subscriber::registry::LookupSpan;
1523

1624
const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB
1725
const SENTRY_DSN: &str =
1826
"https://[email protected]/4510195390611458";
1927
const UPLOAD_TIMEOUT_SECS: u64 = 10;
28+
const FEEDBACK_TAGS_TARGET: &str = "feedback_tags";
29+
const MAX_FEEDBACK_TAGS: usize = 64;
2030

2131
#[derive(Clone)]
2232
pub struct CodexFeedback {
@@ -46,13 +56,50 @@ impl CodexFeedback {
4656
}
4757
}
4858

59+
/// Returns a [`tracing_subscriber`] layer that captures full-fidelity logs into this feedback
60+
/// ring buffer.
61+
///
62+
/// This is intended for initialization code so call sites don't have to duplicate the exact
63+
/// `fmt::layer()` configuration and filter logic.
64+
pub fn logger_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
65+
where
66+
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
67+
{
68+
tracing_subscriber::fmt::layer()
69+
.with_writer(self.make_writer())
70+
.with_ansi(false)
71+
.with_target(false)
72+
// Capture everything, regardless of the caller's `RUST_LOG`, so feedback includes the
73+
// full trace when the user uploads a report.
74+
.with_filter(Targets::new().with_default(Level::TRACE))
75+
}
76+
77+
/// Returns a [`tracing_subscriber`] layer that collects structured metadata for feedback.
78+
///
79+
/// Events with `target: "feedback_tags"` are treated as key/value tags to attach to feedback
80+
/// uploads later.
81+
pub fn metadata_layer<S>(&self) -> impl Layer<S> + Send + Sync + 'static
82+
where
83+
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
84+
{
85+
FeedbackMetadataLayer {
86+
inner: self.inner.clone(),
87+
}
88+
.with_filter(Targets::new().with_target(FEEDBACK_TAGS_TARGET, Level::TRACE))
89+
}
90+
4991
pub fn snapshot(&self, session_id: Option<ConversationId>) -> CodexLogSnapshot {
5092
let bytes = {
5193
let guard = self.inner.ring.lock().expect("mutex poisoned");
5294
guard.snapshot_bytes()
5395
};
96+
let tags = {
97+
let guard = self.inner.tags.lock().expect("mutex poisoned");
98+
guard.clone()
99+
};
54100
CodexLogSnapshot {
55101
bytes,
102+
tags,
56103
thread_id: session_id
57104
.map(|id| id.to_string())
58105
.unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()),
@@ -62,12 +109,14 @@ impl CodexFeedback {
62109

63110
struct FeedbackInner {
64111
ring: Mutex<RingBuffer>,
112+
tags: Mutex<BTreeMap<String, String>>,
65113
}
66114

67115
impl FeedbackInner {
68116
fn new(max_bytes: usize) -> Self {
69117
Self {
70118
ring: Mutex::new(RingBuffer::new(max_bytes)),
119+
tags: Mutex::new(BTreeMap::new()),
71120
}
72121
}
73122
}
@@ -152,6 +201,7 @@ impl RingBuffer {
152201

153202
pub struct CodexLogSnapshot {
154203
bytes: Vec<u8>,
204+
tags: BTreeMap<String, String>,
155205
pub thread_id: String,
156206
}
157207

@@ -212,6 +262,22 @@ impl CodexLogSnapshot {
212262
tags.insert(String::from("reason"), r.to_string());
213263
}
214264

265+
let reserved = [
266+
"thread_id",
267+
"classification",
268+
"cli_version",
269+
"session_source",
270+
"reason",
271+
];
272+
for (key, value) in &self.tags {
273+
if reserved.contains(&key.as_str()) {
274+
continue;
275+
}
276+
if let Entry::Vacant(entry) = tags.entry(key.clone()) {
277+
entry.insert(value.clone());
278+
}
279+
}
280+
215281
let level = match classification {
216282
"bug" | "bad_result" => Level::Error,
217283
_ => Level::Info,
@@ -280,9 +346,80 @@ fn display_classification(classification: &str) -> String {
280346
}
281347
}
282348

349+
#[derive(Clone)]
350+
struct FeedbackMetadataLayer {
351+
inner: Arc<FeedbackInner>,
352+
}
353+
354+
impl<S> Layer<S> for FeedbackMetadataLayer
355+
where
356+
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
357+
{
358+
fn on_event(&self, event: &Event<'_>, _ctx: tracing_subscriber::layer::Context<'_, S>) {
359+
// This layer is filtered by `Targets`, but keep the guard anyway in case it is used without
360+
// the filter.
361+
if event.metadata().target() != FEEDBACK_TAGS_TARGET {
362+
return;
363+
}
364+
365+
let mut visitor = FeedbackTagsVisitor::default();
366+
event.record(&mut visitor);
367+
if visitor.tags.is_empty() {
368+
return;
369+
}
370+
371+
let mut guard = self.inner.tags.lock().expect("mutex poisoned");
372+
for (key, value) in visitor.tags {
373+
if guard.len() >= MAX_FEEDBACK_TAGS && !guard.contains_key(&key) {
374+
continue;
375+
}
376+
guard.insert(key, value);
377+
}
378+
}
379+
}
380+
381+
#[derive(Default)]
382+
struct FeedbackTagsVisitor {
383+
tags: BTreeMap<String, String>,
384+
}
385+
386+
impl Visit for FeedbackTagsVisitor {
387+
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
388+
self.tags
389+
.insert(field.name().to_string(), value.to_string());
390+
}
391+
392+
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
393+
self.tags
394+
.insert(field.name().to_string(), value.to_string());
395+
}
396+
397+
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
398+
self.tags
399+
.insert(field.name().to_string(), value.to_string());
400+
}
401+
402+
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
403+
self.tags
404+
.insert(field.name().to_string(), value.to_string());
405+
}
406+
407+
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
408+
self.tags
409+
.insert(field.name().to_string(), value.to_string());
410+
}
411+
412+
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
413+
self.tags
414+
.insert(field.name().to_string(), format!("{value:?}"));
415+
}
416+
}
417+
283418
#[cfg(test)]
284419
mod tests {
285420
use super::*;
421+
use tracing_subscriber::layer::SubscriberExt;
422+
use tracing_subscriber::util::SubscriberInitExt;
286423

287424
#[test]
288425
fn ring_buffer_drops_front_when_full() {
@@ -296,4 +433,18 @@ mod tests {
296433
// Capacity 8: after writing 10 bytes, we should keep the last 8.
297434
pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij");
298435
}
436+
437+
#[test]
438+
fn metadata_layer_records_tags_from_feedback_target() {
439+
let fb = CodexFeedback::new();
440+
let _guard = tracing_subscriber::registry()
441+
.with(fb.metadata_layer())
442+
.set_default();
443+
444+
tracing::info!(target: FEEDBACK_TAGS_TARGET, model = "gpt-5", cached = true, "tags");
445+
446+
let snap = fb.snapshot(None);
447+
pretty_assertions::assert_eq!(snap.tags.get("model").map(String::as_str), Some("gpt-5"));
448+
pretty_assertions::assert_eq!(snap.tags.get("cached").map(String::as_str), Some("true"));
449+
}
299450
}

codex-rs/tui/src/lib.rs

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ use std::path::PathBuf;
2929
use tracing::error;
3030
use tracing_appender::non_blocking;
3131
use tracing_subscriber::EnvFilter;
32-
use tracing_subscriber::filter::Targets;
3332
use tracing_subscriber::prelude::*;
3433

3534
mod additional_dirs;
@@ -282,13 +281,8 @@ pub async fn run_main(
282281
.with_filter(env_filter());
283282

284283
let feedback = codex_feedback::CodexFeedback::new();
285-
let targets = Targets::new().with_default(tracing::Level::TRACE);
286-
287-
let feedback_layer = tracing_subscriber::fmt::layer()
288-
.with_writer(feedback.make_writer())
289-
.with_ansi(false)
290-
.with_target(false)
291-
.with_filter(targets);
284+
let feedback_layer = feedback.logger_layer();
285+
let feedback_metadata_layer = feedback.metadata_layer();
292286

293287
if cli.oss && model_provider_override.is_some() {
294288
// We're in the oss section, so provider_id should be Some
@@ -323,6 +317,7 @@ pub async fn run_main(
323317
let _ = tracing_subscriber::registry()
324318
.with(file_layer)
325319
.with(feedback_layer)
320+
.with(feedback_metadata_layer)
326321
.with(otel_logger_layer)
327322
.with(otel_tracing_layer)
328323
.try_init();

0 commit comments

Comments
 (0)