Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Features

- Added `SentryStream` and `SentryStreamExt` to `sentry-core`, which bind a `Hub` to a `Stream` so that it is polled within the given hub, mirroring the existing `SentryFuture` and `SentryFutureExt`. Use by bringing `SentryStreamExt` in scope and calling `bind_hub` on a stream ([#1214](https://github.com/getsentry/sentry-rust/pull/1214)).
- The Tower integration's [`SentryHttpLayer`](https://docs.rs/sentry-tower/0.49.3/sentry_tower/struct.SentryHttpLayer.html) 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)).

### Deprecations
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ erased-serde = "0.3.12"
esp-idf-svc = "0.51.0"
findshlibs = "=0.10.2"
futures = "0.3.24"
futures-core = "0.3.24"
futures-util = { version = "0.3.5", default-features = false }
hex = "0.4.3"
hostname = "0.4"
Expand Down
1 change: 1 addition & 0 deletions sentry-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ logs = []
metrics = []

[dependencies]
futures-core = { workspace = true }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: We should avoid adding dependencies when there is not a strong reason why we need the dependency.

In this case, I am not seeing a strong reason for sentry-core to depend on futures-core.

I see a few alternatives that would avoid this unconditional dependency:

  1. It seems we could probably provide the extension trait in a separate crate; perhaps we could name it sentry-futures, kinda like we do for integrations. The crate itself could perhaps later be evolved into a full-fledged integration.
  2. Alternatively, we can put the trait behind a feature-flag. Then, futures-core would be an optional dependency activated by that flag. If we go with this path, I would probably expose the trait in sentry, not sentry-core, unless there's a reason why it needs to be in sentry-core.

log = { workspace = true, features = ["std"], optional = true }
rand = { workspace = true, optional = true }
sentry-types = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions sentry-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
mod intodsn;
mod performance;
mod scope;
mod stream;
mod transport;

// public api or exports from this crate
Expand All @@ -133,6 +134,7 @@
pub use crate::intodsn::IntoDsn;
pub use crate::performance::*;
pub use crate::scope::{Scope, ScopeGuard};
pub use crate::stream::{SentryStream, SentryStreamExt};

Check warning on line 137 in sentry-core/src/lib.rs

View check run for this annotation

@sentry/warden / warden: docs-review

[KZT-QMP] SentryStream docs point at non-existent StreamExt (additional location)

Rustdoc names and links `StreamExt::bind_hub`, but the public trait is `SentryStreamExt`; update the prose and intra-doc link to match.
pub use crate::transport::{Transport, TransportFactory, TransportOptions};
#[cfg(feature = "logs")]
mod logger; // structured logging macros exported with `#[macro_export]`
Expand Down
131 changes: 131 additions & 0 deletions sentry-core/src/stream.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};

use futures_core::Stream;

use crate::Hub;

/// A stream that binds a `Hub` to its polling.
///
/// This activates the given hub for the duration of the inner stream's `poll_next`
/// method. Users usually do not need to construct this type manually, but
/// rather use the [`StreamExt::bind_hub`] method instead.
///
/// [`StreamExt::bind_hub`]: trait.StreamExt.html#method.bind_hub

Check warning on line 15 in sentry-core/src/stream.rs

View check run for this annotation

@sentry/warden / warden: docs-review

SentryStream docs point at non-existent StreamExt

Rustdoc names and links `StreamExt::bind_hub`, but the public trait is `SentryStreamExt`; update the prose and intra-doc link to match.
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SentryStream docs point at non-existent StreamExt

Rustdoc names and links StreamExt::bind_hub, but the public trait is SentryStreamExt; update the prose and intra-doc link to match.

Evidence
  • SentryStream docs say users should call [StreamExt::bind_hub] and link trait.StreamExt.html#method.bind_hub.
  • The exported extension trait is pub trait SentryStreamExt with fn bind_hub, re-exported from sentry-core/src/lib.rs as SentryStreamExt.
  • No public StreamExt exists in this crate, so the rendered docs name and link are wrong.
Also found at 1 additional location
  • sentry-core/src/lib.rs:137

Identified by Warden · docs-review · KZT-QMP

#[derive(Debug)]
pub struct SentryStream<S> {
hub: Arc<Hub>,
stream: S,
}
Comment on lines +17 to +20

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m: If possible, I would make this type private, and adjust SentryStreamExt::bind_hub to return impl Stream<Self::Item>.

Suggested change
pub struct SentryStream<S> {
hub: Arc<Hub>,
stream: S,
}
struct SentryStream<S> {
hub: Arc<Hub>,
stream: S,
}


impl<S> SentryStream<S> {
/// Creates a new bound stream with a `Hub`.
pub fn new(hub: Arc<Hub>, stream: S) -> Self {
Self { hub, stream }
}
}

impl<S> Stream for SentryStream<S>
where
S: Stream,
{
type Item = S::Item;

fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
let hub = self.hub.clone();
// https://doc.rust-lang.org/std/pin/index.html#pinning-is-structural-for-field
let stream = unsafe { self.map_unchecked_mut(|s| &mut s.stream) };

@lcian lcian Jun 30, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible to avoid this direct usage of unsafe (and the one in SentryFuture) by adding a dependency to pin-project-lite.

#[cfg(feature = "client")]
{
let _guard = crate::hub_impl::SwitchGuard::new(hub);
stream.poll_next(cx)
}
#[cfg(not(feature = "client"))]
{
let _ = hub;
stream.poll_next(cx)
}
}
}

/// Stream extensions for Sentry.
pub trait SentryStreamExt: Sized {
/// Binds a hub to this stream.
///
/// This ensures that the stream is polled within the given hub.
fn bind_hub<H>(self, hub: H) -> SentryStream<Self>
where
H: Into<Arc<Hub>>,
{
SentryStream {
stream: self,
hub: hub.into(),
}
}
}
Comment on lines +52 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to adjust this trait a bit in order to be able to return impl Stream

Suggested change
/// Stream extensions for Sentry.
pub trait SentryStreamExt: Sized {
/// Binds a hub to this stream.
///
/// This ensures that the stream is polled within the given hub.
fn bind_hub<H>(self, hub: H) -> SentryStream<Self>
where
H: Into<Arc<Hub>>,
{
SentryStream {
stream: self,
hub: hub.into(),
}
}
}
/// Stream extensions for Sentry.
pub trait SentryStreamExt: Sized + Stream {
/// Binds a hub to this stream.
///
/// This ensures that the stream is polled within the given hub.
fn bind_hub<H>(self, hub: H) -> impl Stream<Item = Self::Item>
where
H: Into<Arc<Hub>>,
{
SentryStream {
stream: self,
hub: hub.into(),
}
}
}


impl<S> SentryStreamExt for S where S: Stream {}

#[cfg(all(test, feature = "test"))]
mod tests {
use crate::test::with_captured_events;
use crate::{capture_error, capture_message, configure_scope, Hub, Level, SentryStreamExt};
use futures::StreamExt;
use tokio::runtime::Runtime;

#[derive(Debug)]
struct TestError(&'static str);

impl std::fmt::Display for TestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}

impl std::error::Error for TestError {}

#[test]
fn test_streams() {
let mut events = with_captured_events(|| {
let runtime = Runtime::new().unwrap();

// Two real streams, each bound to its own hub. The work inside each
// stream runs during `poll_next`, so the captured errors must end up
// tagged with the scope of the hub the stream was bound to.
runtime.block_on(async {
let stream1 = futures::stream::once(async {
configure_scope(|scope| scope.set_transaction(Some("transaction1")));
capture_error(&TestError("oh no from 1"));
})
.bind_hub(Hub::new_from_top(Hub::current()));

let stream2 = futures::stream::once(async {
configure_scope(|scope| scope.set_transaction(Some("transaction2")));
capture_error(&TestError("oh no from 2"));
})
.bind_hub(Hub::new_from_top(Hub::current()));

stream1.collect::<Vec<_>>().await;
stream2.collect::<Vec<_>>().await;
});

capture_message("oh hai from outside", Level::Info);
});

events.sort_by(|a, b| a.transaction.cmp(&b.transaction));
assert_eq!(events.len(), 3);

// The message captured outside any bound stream has no transaction and no
// exception, and sorts first.
assert_eq!(events[0].transaction, None);
assert!(events[0].exception.is_empty());

// The errors captured inside `poll_next` carry the scope of their bound
// hub and the expected exception payload.
assert_eq!(events[1].transaction, Some("transaction1".into()));
assert_eq!(events[1].exception[0].value, Some("oh no from 1".into()));
assert_eq!(events[2].transaction, Some("transaction2".into()));
assert_eq!(events[2].exception[0].value, Some("oh no from 2".into()));
}
}
Loading