From 951babcc9bd0f7584b9fa527a010f1d3d8cac088 Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:11:50 +0200 Subject: [PATCH 1/4] feat(tower): Record http.response.status_code on HTTP transactions SentryHttpLayer already mapped response statuses to SpanStatus, but did not attach the numeric HTTP status as span/transaction data. Record the canonical http.response.status_code attribute from sentry-conventions. Co-Authored-By: Claude --- CHANGELOG.md | 6 +++++ Cargo.lock | 1 + sentry-tower/src/http.rs | 21 ++++++++++----- sentry/Cargo.toml | 3 ++- sentry/tests/test_tower.rs | 55 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688a775fe..f28eda944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New Features + +- `SentryHttpLayer` now records the [`http.response.status_code`](https://getsentry.github.io/sentry-conventions/attributes/http/) attribute on transactions. + ## 0.48.5 ### Fixes diff --git a/Cargo.lock b/Cargo.lock index 5d6d69c8d..f00adfbb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3560,6 +3560,7 @@ dependencies = [ "curl", "embedded-svc", "esp-idf-svc", + "http 1.4.0", "httpdate", "log", "native-tls", diff --git a/sentry-tower/src/http.rs b/sentry-tower/src/http.rs index 5b7c2f4eb..6a7224381 100644 --- a/sentry-tower/src/http.rs +++ b/sentry-tower/src/http.rs @@ -138,12 +138,21 @@ where match slf.future.poll(cx) { Poll::Ready(res) => { if let Some((transaction, parent_span)) = slf.transaction.take() { - if transaction.get_status().is_none() { - let status = match &res { - Ok(res) => map_status(res.status()), - Err(_) => protocol::SpanStatus::UnknownError, - }; - transaction.set_status(status); + match &res { + Ok(res) => { + transaction.set_data( + "http.response.status_code", + res.status().as_u16().into(), + ); + if transaction.get_status().is_none() { + transaction.set_status(map_status(res.status())); + } + } + Err(_) => { + if transaction.get_status().is_none() { + transaction.set_status(protocol::SpanStatus::UnknownError); + } + } } transaction.finish(); sentry_core::configure_scope(|scope| scope.set_span(parent_span)); diff --git a/sentry/Cargo.toml b/sentry/Cargo.toml index 38aa79e82..8bdf94d00 100644 --- a/sentry/Cargo.toml +++ b/sentry/Cargo.toml @@ -96,10 +96,11 @@ esp-idf-svc = { version = "0.51.0", optional = true } sentry-anyhow = { path = "../sentry-anyhow" } sentry-log = { path = "../sentry-log" } sentry-slog = { path = "../sentry-slog" } -sentry-tower = { path = "../sentry-tower" } +sentry-tower = { path = "../sentry-tower", features = ["http"] } sentry-tracing = { path = "../sentry-tracing" } actix-web = { version = "4", default-features = false } anyhow = { version = "1.0.30" } +http = "1.0.0" log = { version = "0.4.8", features = ["std"] } pretty_env_logger = "0.5.0" slog = { version = "2.5.2" } diff --git a/sentry/tests/test_tower.rs b/sentry/tests/test_tower.rs index 4023e2cd3..cc8c51c75 100644 --- a/sentry/tests/test_tower.rs +++ b/sentry/tests/test_tower.rs @@ -3,13 +3,64 @@ use std::sync::Arc; use sentry::{ - protocol::{Breadcrumb, Level}, + protocol::{Breadcrumb, Context, EnvelopeItem, Level, SpanStatus}, test::TestTransport, ClientOptions, Hub, }; -use sentry_tower::SentryLayer; +use sentry_tower::{SentryHttpLayer, SentryLayer}; use tower::{ServiceBuilder, ServiceExt}; +#[test] +fn test_tower_http_records_response_status_code() { + let options = ClientOptions::new().traces_sample_rate(1.0); + + let envelopes = sentry::test::with_captured_envelopes_options( + || { + let service = ServiceBuilder::new() + .layer(SentryHttpLayer::new().enable_transaction()) + .service_fn(|_req: http::Request<()>| async move { + Ok::<_, std::convert::Infallible>( + http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(()) + .unwrap(), + ) + }); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let request = http::Request::builder() + .method(http::Method::GET) + .uri("http://example.com/missing") + .body(()) + .unwrap(); + let response = rt.block_on(service.oneshot(request)).unwrap(); + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + }, + options, + ); + + assert_eq!(envelopes.len(), 1); + let transaction = match envelopes[0].items().next().unwrap() { + EnvelopeItem::Transaction(transaction) => transaction, + _ => panic!("expected a transaction item"), + }; + + assert_eq!(transaction.name.as_deref(), Some("GET /missing")); + let Context::Trace(trace) = transaction.contexts.get("trace").unwrap() else { + panic!("expected a trace context"); + }; + assert_eq!(trace.op.as_deref(), Some("http.server")); + assert_eq!(trace.origin.as_deref(), Some("auto.http.tower")); + assert_eq!(trace.status, Some(SpanStatus::NotFound)); + assert_eq!( + trace.data.get("http.response.status_code"), + Some(&404.into()) + ); +} + #[test] fn test_tower_hub() { // Create a fake transport for new hubs From de566d5610a62048741e9c13cfc10803eb502e4e Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:18:38 +0200 Subject: [PATCH 2/4] feat(tower): Avoid overwriting existing http.response.status_code Only set the attribute when it is not already present, matching the existing guard around transaction status. Co-Authored-By: Claude --- sentry-tower/src/http.rs | 14 +++++++--- sentry/tests/test_tower.rs | 54 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/sentry-tower/src/http.rs b/sentry-tower/src/http.rs index 6a7224381..b384dbeb7 100644 --- a/sentry-tower/src/http.rs +++ b/sentry-tower/src/http.rs @@ -140,10 +140,16 @@ where if let Some((transaction, parent_span)) = slf.transaction.take() { match &res { Ok(res) => { - transaction.set_data( - "http.response.status_code", - res.status().as_u16().into(), - ); + if !transaction + .get_trace_context() + .data + .contains_key("http.response.status_code") + { + transaction.set_data( + "http.response.status_code", + res.status().as_u16().into(), + ); + } if transaction.get_status().is_none() { transaction.set_status(map_status(res.status())); } diff --git a/sentry/tests/test_tower.rs b/sentry/tests/test_tower.rs index cc8c51c75..754ba4c40 100644 --- a/sentry/tests/test_tower.rs +++ b/sentry/tests/test_tower.rs @@ -61,6 +61,60 @@ fn test_tower_http_records_response_status_code() { ); } +#[test] +fn test_tower_http_preserves_existing_response_status_code() { + let options = ClientOptions::new().traces_sample_rate(1.0); + + let envelopes = sentry::test::with_captured_envelopes_options( + || { + let service = ServiceBuilder::new() + .layer(SentryHttpLayer::new().enable_transaction()) + .service_fn(|_req: http::Request<()>| async move { + sentry::configure_scope(|scope| { + if let Some(span) = scope.get_span() { + span.set_data("http.response.status_code", 418.into()); + span.set_status(SpanStatus::InvalidArgument); + } + }); + Ok::<_, std::convert::Infallible>( + http::Response::builder() + .status(http::StatusCode::NOT_FOUND) + .body(()) + .unwrap(), + ) + }); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let request = http::Request::builder() + .method(http::Method::GET) + .uri("http://example.com/tea") + .body(()) + .unwrap(); + let response = rt.block_on(service.oneshot(request)).unwrap(); + assert_eq!(response.status(), http::StatusCode::NOT_FOUND); + }, + options, + ); + + assert_eq!(envelopes.len(), 1); + let transaction = match envelopes[0].items().next().unwrap() { + EnvelopeItem::Transaction(transaction) => transaction, + _ => panic!("expected a transaction item"), + }; + + let Context::Trace(trace) = transaction.contexts.get("trace").unwrap() else { + panic!("expected a trace context"); + }; + assert_eq!(trace.status, Some(SpanStatus::InvalidArgument)); + assert_eq!( + trace.data.get("http.response.status_code"), + Some(&418.into()) + ); +} + #[test] fn test_tower_hub() { // Create a fake transport for new hubs From ff3b6f4644ba91f089553e44bf64bf6bb2ece71b Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:19:57 +0200 Subject: [PATCH 3/4] chore: Drop status-code overwrite test and adjust changelog section Co-Authored-By: Claude --- CHANGELOG.md | 2 +- sentry/tests/test_tower.rs | 54 -------------------------------------- 2 files changed, 1 insertion(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f28eda944..bf0bbcb52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -### New Features +### Features - `SentryHttpLayer` now records the [`http.response.status_code`](https://getsentry.github.io/sentry-conventions/attributes/http/) attribute on transactions. diff --git a/sentry/tests/test_tower.rs b/sentry/tests/test_tower.rs index 754ba4c40..cc8c51c75 100644 --- a/sentry/tests/test_tower.rs +++ b/sentry/tests/test_tower.rs @@ -61,60 +61,6 @@ fn test_tower_http_records_response_status_code() { ); } -#[test] -fn test_tower_http_preserves_existing_response_status_code() { - let options = ClientOptions::new().traces_sample_rate(1.0); - - let envelopes = sentry::test::with_captured_envelopes_options( - || { - let service = ServiceBuilder::new() - .layer(SentryHttpLayer::new().enable_transaction()) - .service_fn(|_req: http::Request<()>| async move { - sentry::configure_scope(|scope| { - if let Some(span) = scope.get_span() { - span.set_data("http.response.status_code", 418.into()); - span.set_status(SpanStatus::InvalidArgument); - } - }); - Ok::<_, std::convert::Infallible>( - http::Response::builder() - .status(http::StatusCode::NOT_FOUND) - .body(()) - .unwrap(), - ) - }); - - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap(); - let request = http::Request::builder() - .method(http::Method::GET) - .uri("http://example.com/tea") - .body(()) - .unwrap(); - let response = rt.block_on(service.oneshot(request)).unwrap(); - assert_eq!(response.status(), http::StatusCode::NOT_FOUND); - }, - options, - ); - - assert_eq!(envelopes.len(), 1); - let transaction = match envelopes[0].items().next().unwrap() { - EnvelopeItem::Transaction(transaction) => transaction, - _ => panic!("expected a transaction item"), - }; - - let Context::Trace(trace) = transaction.contexts.get("trace").unwrap() else { - panic!("expected a trace context"); - }; - assert_eq!(trace.status, Some(SpanStatus::InvalidArgument)); - assert_eq!( - trace.data.get("http.response.status_code"), - Some(&418.into()) - ); -} - #[test] fn test_tower_hub() { // Create a fake transport for new hubs From 516a91962b2f9e26134f6d4f3453b9a8953a6bfa Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:21:32 +0200 Subject: [PATCH 4/4] chore: Link changelog entry to PR #1253 Danger requires the PR number in Unreleased changelog entries. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0bbcb52..cfa845480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features -- `SentryHttpLayer` now records the [`http.response.status_code`](https://getsentry.github.io/sentry-conventions/attributes/http/) attribute on transactions. +- `SentryHttpLayer` now records the [`http.response.status_code`](https://getsentry.github.io/sentry-conventions/attributes/http/) attribute on transactions ([#1253](https://github.com/getsentry/sentry-rust/pull/1253)). ## 0.48.5