Skip to content

feat: Make transport channel capacity configurable - #1040

Open
mvanhorn wants to merge 1 commit into
getsentry:masterfrom
mvanhorn:feat/configurable-transport-channel-capacity
Open

feat: Make transport channel capacity configurable#1040
mvanhorn wants to merge 1 commit into
getsentry:masterfrom
mvanhorn:feat/configurable-transport-channel-capacity

Conversation

@mvanhorn

@mvanhorn mvanhorn commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds transport-level with_channel_capacity constructors so users can tune the bounded channel size used by the transport thread. The default remains 30, preserving existing behavior.

The original design added a transport_channel_capacity field to ClientOptions. Per @szokeasaurusrex's review, refactored to additive transport-level methods so ClientOptions stays minimal and the feature is opt-in at the transport boundary.

Why this matters

In high-throughput scenarios (many transactions with single spans each), the hardcoded capacity of 30 can saturate quickly, leading to dropped envelopes. Identified in #923, tracked in #994. Making it configurable lets users trade memory for reliability based on their workload.

Changes

  • sentry/src/transports/thread.rs: TransportThread::new(send) keeps its original signature. New TransportThread::with_capacity(send, channel_capacity) accepts a custom capacity, clamped to a minimum of 1 to avoid rendezvous channels.
  • sentry/src/transports/tokio_thread.rs: Same pattern for the async transport variant.
  • sentry/src/transports/curl.rs: Added CurlHttpTransport::with_channel_capacity(options, channel_capacity). new(options) unchanged.
  • sentry/src/transports/reqwest.rs: Added ReqwestHttpTransport::with_channel_capacity(options, channel_capacity). new(options) / with_client(options, client) unchanged.
  • sentry/src/transports/ureq.rs: Added UreqHttpTransport::with_channel_capacity(options, channel_capacity). new(options) / with_agent(options, agent) unchanged.

Usage

let opts = ClientOptions {
    transport: Some(Arc::new(move |opts| {
        Arc::new(ReqwestHttpTransport::with_channel_capacity(opts, 256))
    })),
    ..Default::default()
};

Testing

  • cargo check --workspace --all-features passes
  • cargo fmt --all clean
  • cargo clippy --workspace --all-features clean
  • Default behavior unchanged (capacity stays at 30 unless explicitly configured)

Closes #994

This contribution was developed with AI assistance (Claude Code).

Comment thread sentry-core/src/clientoptions.rs Outdated
Comment thread sentry/src/transports/thread.rs Outdated
Comment thread sentry-core/src/clientoptions.rs Outdated
@codecov

codecov Bot commented Apr 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.59016% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.76%. Comparing base (a57b91c) to head (f19e186).
⚠️ Report is 133 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1040      +/-   ##
==========================================
+ Coverage   73.81%   74.76%   +0.95%     
==========================================
  Files          64       76      +12     
  Lines        7538     9488    +1950     
==========================================
+ Hits         5564     7094    +1530     
- Misses       1974     2394     +420     

@szokeasaurusrex

Copy link
Copy Markdown
Member

Hi @mvanhorn, thanks for the contribution! Before I proceed to a full review, please address the CI failures and the AI review agent comments. If any of the AI review comments are inaccurate, please comment on them and mark as resolved. Thanks!

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Addressed in 9c8f904:

  • Added transport_channel_capacity to the manual Debug impl
  • Clamped capacity to min 1 to prevent rendezvous channel (try_send would silently drop)
  • Ran cargo fmt

@szokeasaurusrex szokeasaurusrex left a comment

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.

Thanks again for the contribution. I think we should revise the public API shape to avoid public API breakages before merging this change.

The current implementation introduces public API breakage by:

  • adding a new public field to ClientOptions
  • changing the signatures of the public transport thread constructors

I would like to avoid those breakages here and instead expose this as additive API:

  • add additive with_capacity(...) constructors on the public transport thread types, i.e. StdTransportThread::with_capacity(send, channel_capacity) and TokioTransportThread::with_capacity(send, channel_capacity)
  • keep the existing new(...) signatures unchanged, but make those constructors delegate to with_capacity(..., 30)
  • add transport-specific constructors such as ReqwestHttpTransport::with_channel_capacity(options, channel_capacity) (and similarly for curl and ureq), which would use those new with_capacity(...) thread constructors internally
  • document use through ClientOptions.transport

That still gives users a way to override the transport queue capacity via the existing TransportFactory mechanism, without changing ClientOptions yet.

For example, initializing the SDK with a custom capacity could look like this:

let opts = ClientOptions {
    transport: Some(Arc::new(move |opts| {
        Arc::new(ReqwestHttpTransport::with_channel_capacity(opts, 256))
    })),
    ..Default::default()
};

If you are open to it, please refactor the PR in that direction. If not, let me know and I can take it over as a follow-up.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks for the direction @szokeasaurusrex. Refactored to the additive shape you described in fbff3ea:

  • TransportThread::new(send) restored to the original signature. New TransportThread::with_capacity(send, channel_capacity) is the entry point for custom capacity (clamped to a minimum of 1 to avoid the rendezvous channel). Same pattern in tokio_thread::TransportThread.
  • ReqwestHttpTransport::with_channel_capacity(options, channel_capacity) added. new(options) / with_client(options, client) keep the default capacity of 30. Same for CurlHttpTransport and UreqHttpTransport (with_agent likewise unchanged).
  • Removed transport_channel_capacity from ClientOptions (field, Default, and the manual Debug impl).

Your example works unchanged:

let opts = ClientOptions {
    transport: Some(Arc::new(move |opts| {
        Arc::new(ReqwestHttpTransport::with_channel_capacity(opts, 256))
    })),
    ..Default::default()
};

cargo check --workspace --all-features, cargo fmt --all, and cargo clippy --workspace --all-features all pass.

Comment thread sentry/src/transports/curl.rs Outdated
Comment thread sentry/src/transports/reqwest.rs Outdated
@lcian
lcian removed their request for review April 15, 2026 09:49
@mvanhorn

Copy link
Copy Markdown
Contributor Author

@szokeasaurusrex - the refactor in fbff3ea matches the additive-API shape you requested. I've also replied to and resolved the two stale bot comments from Apr 15, which were analyzing against the original design. Verified locally: cargo build --features reqwest succeeds and the sentry crate tests pass. Ready for your full review whenever you have time.

@szokeasaurusrex

Copy link
Copy Markdown
Member

@mvanhorn I have this PR on my list of things to review. I am busy with another project at the moment but will try to review this within the next week or so

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Thanks @szokeasaurusrex - no rush, whenever you get a chance.

@szokeasaurusrex szokeasaurusrex left a comment

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.

Thanks for your patience for my review. I added some small comments; this looks pretty good overall though!

Comment thread sentry/src/transports/tokio_thread.rs Outdated
/// `send` blocks. `channel_capacity` is clamped to a minimum of 1 to
/// avoid a rendezvous channel, which would silently drop envelopes under
/// `try_send`.
pub fn with_capacity<SendFn, SendFuture>(mut send: SendFn, channel_capacity: usize) -> Self

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: Let's make this pub(crate) for now to limit public API surface. If folks want to have this as a public API in the future, we can expose it at that time.

Suggested change
pub fn with_capacity<SendFn, SendFuture>(mut send: SendFn, channel_capacity: usize) -> Self
pub(crate) fn with_capacity<SendFn, SendFuture>(mut send: SendFn, channel_capacity: usize) -> Self

Comment thread sentry/src/transports/thread.rs Outdated
/// `send` blocks. `channel_capacity` is clamped to a minimum of 1 to
/// avoid a rendezvous channel, which would silently drop envelopes under
/// `try_send`.
pub fn with_capacity<SendFn>(mut send: SendFn, channel_capacity: usize) -> Self

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: Let's make this pub(crate) for now to limit public API surface. If folks want to have this as a public API in the future, we can expose it at that time.

Suggested change
pub fn with_capacity<SendFn>(mut send: SendFn, channel_capacity: usize) -> Self
pub(crate) fn with_capacity<SendFn>(mut send: SendFn, channel_capacity: usize) -> Self

Comment thread sentry/src/transports/curl.rs Outdated
/// Creates a new Transport.
pub fn new(options: &ClientOptions) -> Self {
Self::new_internal(options, None)
Self::new_internal(options, None, 30)

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: As we use the number 30 as the default channel capacity in all the transports, we should extract it to a constant that we reuse in all of them.

@mvanhorn

mvanhorn commented May 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 6d1c9ff:

  • Both TransportThread::with_capacity are now pub(crate) per your suggestion.
  • Extracted the 30 default to pub(crate) const DEFAULT_CHANNEL_CAPACITY: usize = 30 in transports/mod.rs and reused it across curl, reqwest, and ureq transports (plus both thread modules' new defaults). Open to moving the const to transports/thread.rs instead if that reads cleaner -- happy to follow your preference.

Verified cargo fmt --check, cargo build --features reqwest, and cargo build --features curl locally before pushing.

@szokeasaurusrex szokeasaurusrex left a comment

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.

Hey, thanks for addressing those! I just have one more thought about the clamping, then I think this is good to merge

Comment thread sentry/src/transports/thread.rs Outdated
where
SendFn: FnMut(Envelope, &mut RateLimiter) + Send + 'static,
{
let (sender, receiver) = sync_channel(channel_capacity.max(1));

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.

I think we should honor 0 here instead of clamping it. This is an advanced, opt-in transport API, and sync_channel(0) has defined rendezvous/no-buffer semantics. A capacity of 1 can still drop most events under bursts; it is not a general safeguard, only a different buffering policy. If someone explicitly passes 0, treating that as “no queued buffering” seems reasonable.

I tested this end-to-end with the clamp removed and with_channel_capacity(..., 0): sending 10 rapid events accepted 1 and dropped 9. That matches the channel semantics: zero capacity does not necessarily drop everything; it accepts an envelope when try_send happens while the transport thread is already waiting on the receiver. If we keep support for 0, the doc comment should describe that behavior rather than saying it would silently drop envelopes generally.

Comment thread sentry/src/transports/tokio_thread.rs Outdated
// NOTE: returning RateLimiter here, otherwise we are in borrow hell
SendFuture: std::future::Future<Output = RateLimiter>,
{
let (sender, receiver) = sync_channel(channel_capacity.max(1));

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.

Same concept applies here; I tested both transport thread variants with the clamp removed, and both accepted 1 of 10 rapid events with capacity 0 rather than dropping everything.

@mvanhorn

mvanhorn commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

Done in c819ce9 - dropped the .max(1) clamp in both transports/thread.rs and transports/tokio_thread.rs, and updated the doc comments to describe the rendezvous semantics rather than implying capacity 0 drops everything. Local cargo build --features reqwest and cargo test -p sentry --features reqwest --lib pass; cargo fmt --check clean.

Comment thread sentry/src/transports/thread.rs Outdated
Comment on lines 46 to 53
pub(crate) fn with_capacity<SendFn>(mut send: SendFn, channel_capacity: usize) -> Self
where
SendFn: FnMut(Envelope, &mut RateLimiter) + Send + 'static,
{
let (sender, receiver) = sync_channel(channel_capacity);
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_worker = shutdown.clone();
let handle = thread::Builder::new()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: With channel_capacity=0, flush() and drop() use a blocking send() for control tasks, which can cause the caller to hang if the worker thread is busy.
Severity: MEDIUM

Suggested Fix

Consider using try_send() for control tasks like Task::Flush and Task::Shutdown, similar to how envelopes are handled. This would align with the documented behavior and prevent blocking in flush() and drop() when the channel capacity is zero and the worker is busy.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: sentry/src/transports/thread.rs#L46-L53

Potential issue: When the transport thread is configured with `channel_capacity=0`, it
creates a rendezvous channel. However, the `flush()` and `drop()` methods use a blocking
`send()` to dispatch control tasks (`Task::Flush`, `Task::Shutdown`). If the worker
thread is occupied with a long-running operation, such as sending an envelope over HTTP,
it cannot receive new tasks. Consequently, the call to `flush()` will block until its
timeout is reached, and more critically, `drop()` will block the calling thread (e.g.,
during application shutdown) until the long-running operation completes. This can lead
to significant delays or hangs during shutdown for users who opt into this advanced
configuration. The documentation is also misleading, as it implies non-blocking behavior
for all sends.

Also affects:

  • sentry/src/transports/tokio_thread.rs:48~55

@mvanhorn
mvanhorn requested a review from a team as a code owner July 15, 2026 22:47
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Rebased onto master and resolved the conflict with the new transport-options refactor. Two things:

  1. The clamp is gone (c819ce9) - capacity 0 now creates a rendezvous channel as you asked, no .max(1).

  2. To fit your new options-builder pattern, I moved capacity config onto the transport options builders: CurlHttpTransportOptions::with_channel_capacity(n) (and the Reqwest/Ureq equivalents), backed by TransportThreadOptions::with_channel_capacity(n). That drops the old transport-level with_channel_capacity(&ClientOptions, n) convenience in favor of the builder, which felt more consistent now that new()/with_client() are deprecated - happy to add a convenience method back if you'd rather.

Verified locally with --all-features: build, all tests, clippy -D clippy::all, and rustfmt all clean.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed 58951b1. Addressed the capacity-0 flush/drop hang the bot flagged: flush() and drop() now use try_send() for their control tasks (envelope enqueue was already try_send), and the sender is wrapped in Option so Drop can take it cleanly, so a rendezvous channel never blocks the caller on flush or shutdown. Added a regression test in both the std and tokio transports asserting flush() returns false instead of blocking on a busy capacity-0 channel. Also made with_channel_capacity pub(crate) in both TransportThreadOptions to keep the public surface limited as you asked. The shared DEFAULT_CHANNEL_CAPACITY constant and honoring 0 landed in the earlier round.

Comment thread sentry/src/transports/thread.rs Outdated
Comment thread sentry/src/transports/thread.rs Outdated
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed 3cda078. Capacity 0 stays supported — flush and shutdown now go through a dedicated control channel rather than the envelope channel, so they can't deadlock at capacity 0, and flush drains pending envelopes instead of dropping them via try_send. Added a changelog entry. (ESP-IDF CI and the ureq-feature clippy expectation I couldn't run locally — the latter looks pre-existing in sentry-core, but flag it if not.)

Comment thread sentry/src/transports/thread.rs
Comment thread sentry/src/transports/thread.rs

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 780d015. Configure here.

Comment thread sentry/src/transports/thread.rs Outdated
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed 780d015: Drop drains pending envelopes before shutting the worker down (no more silent loss when the transport is dropped mid-queue), and the control-channel check no longer delays idle flushes. Transport suite is green locally (6/6) along with fmt.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Pushed the fix for the flagged transport channel-capacity handling — pending envelopes are now drained on Drop and the idle flush stays responsive (thread.rs / tokio_thread.rs). Verified locally with cargo fmt, the exact CI clippy, and the full all-features/all-targets test + doc-test suite. One heads-up: the branch is a fair way behind master, so it'll need a rebase before it can merge — I'll get that sorted separately.

@mvanhorn

Copy link
Copy Markdown
Contributor Author

This is ready for another look whenever you have time. The requested changes went in at 8c14fa8, which landed after the last review pass, and all 16 review threads are resolved.

Re-verified locally on that commit: the timeout-preserving Drop fix and its regression tests are present for both transport variants, and rustfmt, an offline build, Clippy, and the focused tests all pass.

No rush, just flagging that the ball is back on your side.

Comment thread sentry/src/transports/thread.rs
@mvanhorn

mvanhorn commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in afa4f65.

The two HIGH Drop findings are the same defect from two angles: Drop sent a Flush and blocked on recv() with no bound. That hangs on a stuck worker, and it also made Client::close's timed shutdown pointless, since dropping right after blocked anyway. Drop now runs a shutdown bounded by DROP_FLUSH_TIMEOUT.

I kept the join when the flush completed, so a healthy worker is still awaited and a worker panic still surfaces rather than being swallowed. Only a timed-out flush skips it, since join() has no timeout and would put the unbounded wait right back. Draining on a normal drop is unchanged, so 780d015 isn't regressed.

On the capacity finding: I'd documented 0 as a deliberate rendezvous / no-buffer back-pressure mode, but the Bugbot point stands. With send using try_send, that reads as back-pressure and behaves as near-total silent loss, and nothing warned. Clamped to a minimum of 1. Happy to reinstate the rendezvous behaviour behind something more explicit if you'd rather have it available.

Both the std and tokio transport threads. 9/9 transport tests pass locally, clippy and fmt clean.

Comment thread sentry/src/transports/curl.rs
…ransports

Add `channel_capacity` to the built-in background transports so callers can
size the envelope queue, with `with_channel_capacity` keeping the change
additive against the existing constructors.

A capacity of 0 is honored as a rendezvous channel rather than silently
clamped, and flush/shutdown are routed through a dedicated control channel so
they cannot be starved by a full envelope queue or race the worker.

Drop is bounded: it runs a shutdown with the configured timeout before the
final drain instead of blocking indefinitely on recv(). Previously a stuck
worker could hang Drop forever, which also defeated the timed shutdown in
`Client::close`, since dropping right after would block anyway.

Regression tests cover the rendezvous capacity, the control-channel path, and
the bounded Drop for both the thread and tokio transports.

Signed-off-by: Matt Van Horn <mvanhorn@gmail.com>
@mvanhorn
mvanhorn force-pushed the feat/configurable-transport-channel-capacity branch from afa4f65 to e428a56 Compare August 8, 2026 04:43
@mvanhorn

mvanhorn commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto master (0.49.1) and squashed to a single commit.

The branch had accumulated 16 commits, most of them fork-sync duplicates of upstream work under different SHAs, which is also why it went conflicting. The transport code is byte-identical to what you last reviewed at afa4f65 - only the history and the CHANGELOG placement changed. My entry now sits under a new Unreleased heading above 0.49.1 rather than inside it.

@szokeasaurusrex the clamping thought from your last pass is in, and both Bugbot Drop findings are covered by named regression tests that pass on this commit:

  • drop_does_not_wait_for_a_stuck_worker
  • timed_out_shutdown_does_not_block_drop_or_drain_queued_envelopes

Full run: 9/9 on sentry --lib transports.

Comment on lines +235 to +241
/// Set the capacity of the channel that queues envelopes for the background
/// transport thread (default: 30).
///
/// A capacity of `0` creates a rendezvous channel: an envelope is accepted
/// only when the transport thread is currently waiting on the receiver,
/// otherwise it is dropped. A higher capacity reduces the chance of dropped
/// events in high-throughput scenarios at the cost of memory.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The documentation for with_channel_capacity(0) incorrectly claims it creates a rendezvous channel. The implementation actually creates a buffered channel with a capacity of 1.
Severity: LOW

Suggested Fix

Update the documentation in reqwest.rs, ureq.rs, and curl.rs to accurately reflect the implementation. The documentation should state that a capacity of 0 is clamped to 1, resulting in a buffered channel.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: sentry/src/transports/reqwest.rs#L235-L241

Potential issue: The public API documentation for `with_channel_capacity` in
`ReqwestHttpTransportOptions`, `UreqHttpTransportOptions`, and
`CurlHttpTransportOptions` incorrectly states that passing a `channel_capacity` of `0`
will create a rendezvous channel. The implementation, however, uses a
`normalize_channel_capacity` function which clamps any value less than 1 to 1. This
means that `with_channel_capacity(0)` actually creates a buffered channel with a
capacity of 1, not a rendezvous channel. This discrepancy between the documented
behavior and the actual implementation can lead to unexpected queuing of one envelope,
violating the contract promised to the user.

Also affects:

  • sentry/src/transports/ureq.rs:257~263

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make transport channel capacity configurable

2 participants