diff --git a/backend/crates/providers/openai/src/transport/correlation.rs b/backend/crates/providers/openai/src/transport/correlation.rs index 44b216317..64c06d51a 100644 --- a/backend/crates/providers/openai/src/transport/correlation.rs +++ b/backend/crates/providers/openai/src/transport/correlation.rs @@ -26,8 +26,12 @@ const METADATA_FIELDS: &[&str] = &["turnMetadata", "turn_metadata", "x-codex-tur const TRACE_FIELDS: &[&str] = &[ "traceparent", "tracestate", + "sentry-trace", + "baggage", "ws_request_header_traceparent", "ws_request_header_tracestate", + "ws_request_header_sentry-trace", + "ws_request_header_baggage", ]; // Live 的 x-session-id 与 voice-session-id 是业务会话引用,必须原样传递 const ID_HEADERS: &[&str] = &[ diff --git a/backend/crates/providers/openai/tests/provider/contract/correlation.rs b/backend/crates/providers/openai/tests/provider/contract/correlation.rs index 58889c1e5..568dc2049 100644 --- a/backend/crates/providers/openai/tests/provider/contract/correlation.rs +++ b/backend/crates/providers/openai/tests/provider/contract/correlation.rs @@ -6,7 +6,8 @@ fn metadata() -> Value { json!({"session_id":"source-session", "thread_id":"source-thread", "turn_id":"source-turn", "parent_thread_id":"source-session", "forked_from_thread_id":"source-session", "parent_turn_id":"source-turn", "root_turn_id":"source-turn", - "window_id":"source-window", "context_window_id":"source-window", "future":{"keep":true}}) + "window_id":"source-window", "context_window_id":"source-window", "future":{"keep":true}, + "sentry-trace":"source-trace", "baggage":"sentry-environment=synthetic"}) } fn payload(websocket: bool) -> ProtocolPayload { @@ -43,7 +44,9 @@ fn payload(websocket: bool) -> ProtocolPayload { "x-codex-window-id":"source-window", "x-codex-parent-thread-id":"source-session", "x-codex-turn-metadata":full.to_string(), "ws_request_header_traceparent":"00-11111111111111111111111111111111-2222222222222222-01", - "ws_request_header_tracestate":"vendor=source" + "ws_request_header_tracestate":"vendor=source", + "ws_request_header_sentry-trace":"source-trace", + "ws_request_header_baggage":"sentry-environment=synthetic" } }).as_object().unwrap().clone()).unwrap().with_context(Map::from_iter([ ("use_websocket".into(), json!(websocket)), @@ -53,7 +56,10 @@ fn payload(websocket: bool) -> ProtocolPayload { ("opaque_request_headers".into(), json!([ ["x-client-turn-id",STANDARD.encode("source-turn")], ["traceparent",STANDARD.encode("00-11111111111111111111111111111111-2222222222222222-01")], - ["tracestate",STANDARD.encode("vendor=source")] + ["tracestate",STANDARD.encode("vendor=source")], + ["Sentry-Trace",STANDARD.encode("source-trace")], + ["baggage",STANDARD.encode("sentry-environment=synthetic")], + ["baggage",STANDARD.encode("sentry-release=synthetic")] ])), ])) } @@ -132,9 +138,13 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc let (headers, body) = capture.await; assert!(!headers.contains_key("traceparent")); assert!(!headers.contains_key("tracestate")); + assert!(!headers.contains_key("sentry-trace")); + assert!(!headers.contains_key("baggage")); let flat = &body["client_metadata"]; assert!(flat.get("ws_request_header_traceparent").is_none()); assert!(flat.get("ws_request_header_tracestate").is_none()); + assert!(flat.get("ws_request_header_sentry-trace").is_none()); + assert!(flat.get("ws_request_header_baggage").is_none()); let header: Value = serde_json::from_str(headers["x-codex-turn-metadata"].to_str().unwrap()).unwrap(); let full: Value = @@ -153,6 +163,8 @@ async fn account_correlation_preserves_turns_across_transports_and_account_switc assert_eq!(flat["x-client-turn-id"], "source-turn"); assert_eq!(flat["turn_id"], "source-turn"); for metadata in [&header, &full] { + assert!(metadata.get("sentry-trace").is_none()); + assert!(metadata.get("baggage").is_none()); assert_eq!(metadata["turn_id"], "source-turn"); assert_eq!(metadata["parent_turn_id"], "source-turn"); assert_eq!(metadata["root_turn_id"], "source-turn"); diff --git a/backend/crates/providers/openai/tests/provider/contract/privacy.rs b/backend/crates/providers/openai/tests/provider/contract/privacy.rs index 8ee548f70..1a385b421 100644 --- a/backend/crates/providers/openai/tests/provider/contract/privacy.rs +++ b/backend/crates/providers/openai/tests/provider/contract/privacy.rs @@ -282,16 +282,18 @@ async fn privacy_rewrites_websocket_handshake_and_frame_once() { } #[tokio::test] -async fn privacy_header_change_uses_a_new_websocket_pool_connection() { +async fn privacy_header_change_isolates_websocket_then_reuses_stable_headers() { let store = Arc::new(MemoryAccountStore::default()); create_account(&store, "acct_provider_contract").await; let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); let captured = tokio::spawn(async move { let mut sockets = Vec::new(); - for expected in [ - "Bearer first-synthetic-token", - "Bearer second-synthetic-token", + let mut cache_keys = Vec::new(); + let mut session_headers = Vec::new(); + for (expected, requests) in [ + ("Bearer first-synthetic-token", 1), + ("Bearer second-synthetic-token", 2), ] { let (stream, _) = timeout(Duration::from_secs(5), listener.accept()) .await @@ -300,35 +302,81 @@ async fn privacy_header_change_uses_a_new_websocket_pool_connection() { let mut socket = crate::transport::accept_codex_test_websocket_with(stream, |request, response| { assert_eq!(request.headers()["authorization"], expected); + assert!(!request.headers().contains_key("sentry-trace")); + assert!(!request.headers().contains_key("baggage")); + session_headers.push(request.headers()["session-id"].clone()); response.headers_mut().insert( "sec-websocket-extensions", "permessage-deflate".parse().unwrap(), ); }) .await; - let frame = socket.next().await.unwrap().unwrap().into_text().unwrap(); - assert_eq!( - serde_json::from_str::(&frame).unwrap()["input"][0]["content"][0]["text"], - "hellohello" - ); - socket.send(Message::Text(json!({"type":"response.completed","response":{"id":"resp_privacy_pool","model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap(); + for _ in 0..requests { + let frame = timeout(Duration::from_secs(5), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap() + .into_text() + .unwrap(); + let body: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(body["input"][0]["content"][0]["text"], "hellohello"); + cache_keys.push(body["prompt_cache_key"].as_str().unwrap().to_owned()); + socket.send(Message::Text(json!({"type":"response.completed","response":{"id":format!("resp_privacy_pool_{}",cache_keys.len()),"model":"gpt-5.4","status":"completed","output":[]}}).to_string().into())).await.unwrap(); + } // 保留第一条连接,确保下一次握手是池隔离的结果而非断线重连 sockets.push(socket); } + assert_eq!(session_headers[0], session_headers[1]); + assert_eq!(cache_keys.len(), 3); + assert!(cache_keys.iter().all(|key| key == &cache_keys[0])); sockets }); let provider = provider_with_base_url(&store, format!("http://{address}")); - for token in [ - "Bearer first-synthetic-token", - "Bearer second-synthetic-token", + for (token, trace) in [ + ("Bearer first-synthetic-token", Some("first-trace")), + ("Bearer second-synthetic-token", Some("second-trace")), + ("Bearer second-synthetic-token", None), ] { let mut config = outbound_policy(true); config.rules[0].steps[0].value = json!(token); - let operation = Operation::Generate(generate_with_session_context( - "privacy-pool", - Some("privacy-thread"), - None, - )); + let original = generate_with_session_context("privacy-pool", Some("privacy-thread"), None); + let mut body = original.protocol_payload().body().clone(); + body.insert("prompt_cache_key".into(), json!("privacy-pool-cache")); + let mut context = original.protocol_payload().context().clone(); + if let Some(trace) = trace { + context.insert( + "opaque_request_headers".into(), + json!([ + ["sentry-trace", STANDARD.encode(trace)], + [ + "baggage", + STANDARD.encode(format!("sentry-trace_id={trace}")) + ] + ]), + ); + } + let operation = Operation::Generate( + GenerateRequest::from_protocol_payload( + ProtocolPayload::json_object("openai", body) + .unwrap() + .with_context(context), + ) + .with_provider_session_state( + ProviderSessionState::new( + "openai", + json!({ + "account_id":"acct_provider_contract", + "conversation_id":"privacy-pool", + "continuation_scope":"persisted" + }) + .as_object() + .unwrap() + .clone(), + ) + .unwrap(), + ), + ); let mut stream = provider .clone() .execute( diff --git a/docs/api.md b/docs/api.md index 4980e928e..78c9714bc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -409,8 +409,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和 两者同时存在时仍优先使用 `session-id`。正文中的 `client_metadata.session_id`、 `prompt_cache_key` 不受这条请求头规则影响。 `thread-id`、turn metadata 等 Codex 协议字段及未知业务扩展不受下游环境头过滤规则影响; -出站移除下游的 `traceparent`、`tracestate` 及 WS metadata 中对应的 -`ws_request_header_traceparent`、`ws_request_header_tracestate` +出站移除下游的 `traceparent`、`tracestate`、`sentry-trace`、`baggage`, +以及 WS metadata 中对应的 `ws_request_header_*` 副本 会话、线程、窗口及父子线程引用在出站时按本次安装身份稳定映射为 UUID; 默认使用账号安装身份,同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及 @@ -1887,6 +1887,10 @@ JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、` 删除动作的 `pattern: null` 表示无条件删除,字符串模式表示只删除字符串值匹配的字段或数组项。 请求头规则生成的值必须是 ASCII 文本,以保持 HTTP 与 WebSocket opening 的一致性;metadata 范围会自动转义 Unicode +WebSocket 请求头在握手时发送。隐私规则改变握手头时,普通请求按对应连接身份选择连接, +处理结果稳定时可继续复用,不因每次执行删除动作而重连。 +依赖原连接的精确续接保留原连接,规则变更不能撤回已有握手发送的值 + 例如,`turn_metadata` 范围的 `$.workspaces` 配合 `remove_field` 删除整个工作区映射; `$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端; `$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构