Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4f8a00c
feat: Add experimental async transport (port of PR #4572)
BYK Mar 12, 2026
82c0094
fix: Suppress mypy await type error in AsyncHttpTransport._request
BYK Mar 12, 2026
4b77519
fix: Move httpcore[asyncio] from global test deps to specific envs
BYK Mar 12, 2026
c46fb6f
fix: Cancel _target task in AsyncWorker.kill() and improve sync close()
BYK Mar 12, 2026
5ea3aac
fix: Skip async tests under gevent
BYK Mar 12, 2026
ff85a58
fix: Remove from __future__ import annotations for Python 3.6 compat
BYK Mar 16, 2026
156f32b
test: Add comprehensive coverage tests for async transport
BYK Mar 16, 2026
cb932d2
fix: Make test_async_worker_start_no_running_loop sync
BYK Mar 16, 2026
d19271e
fix: Add asyncio.sleep(0) after worker.kill() to clean up coroutines
BYK Mar 16, 2026
299947d
fix: Handle CancelledError in AsyncWorker._target
BYK Mar 16, 2026
71007ec
fix: Suppress PytestUnraisableExceptionWarning for async worker tests
BYK Mar 16, 2026
183e83b
Merge origin/master into feat/async-transport
BYK Mar 20, 2026
8883b78
test: Add sync wrapper tests for async code paths (coverage)
BYK Mar 20, 2026
86d6e36
fix: Address Bugbot feedback — stale terminator and flush components
BYK Mar 20, 2026
e74f4a7
Merge origin/master into feat/async-transport
BYK Mar 23, 2026
91072bb
fix: Address bot feedback from merge
BYK Mar 23, 2026
d64517f
test: Add pure-sync mock-based tests for async code coverage
BYK Mar 23, 2026
94b6c73
fix: Capture queue ref at dispatch time in _on_task_complete
BYK Mar 23, 2026
38f97c2
refactor: Address reviewer feedback
BYK Mar 26, 2026
1ac4196
fix: Guard isinstance AsyncHttpTransport with ASYNC_TRANSPORT_ENABLED
BYK Mar 26, 2026
b990610
fix: Skip loop close test when async transport deps missing
BYK Mar 26, 2026
b392bc4
refactor: Simplify and deduplicate async transport code
BYK Mar 26, 2026
36ad606
fix: Remove unused type:ignore on async method overrides
BYK Mar 26, 2026
025714e
fix: Always add keep-alive socket options in httpcore transports
BYK Mar 26, 2026
ff3e9a0
refactor: Address reviewer feedback — rename, simplify, remove tests
BYK Mar 27, 2026
9b0a712
fix: Re-add httpcore[asyncio] to tox.ini and fix renamed import
BYK Mar 27, 2026
f59c38c
fix: Fix test assertions for warnings.warn and missing decorators
BYK Mar 27, 2026
94666e1
remove extra section from agents.md
sentrivana Mar 30, 2026
a2a9588
Merge branch 'master' into feat/async-transport
sentrivana Mar 30, 2026
a8823a9
regen tox
sentrivana Mar 30, 2026
ed040a1
remove couple of tests
sentrivana Mar 30, 2026
ad2ceae
turn keep alive on by default in httpcore based transports
sentrivana Mar 30, 2026
4a2a6a3
only enable http2 on async transport if h2 is there
sentrivana Mar 30, 2026
c88848e
Merge branch 'master' into feat/async-transport
sentrivana Mar 30, 2026
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
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,18 @@ Do NOT edit these directly — modify source scripts instead:
| `sentry_sdk/profiler/` | Performance profiling |
| `tests/integrations/{name}/` | Integration test suites |
| `scripts/populate_tox/config.py` | Test suite configuration |

<!-- This section is maintained by the coding agent via lore (https://github.com/BYK/opencode-lore) -->
## Long-term Knowledge

### Gotcha

<!-- lore:019cc484-f0e1-7016-a851-177fb9ad2cc4 -->
* **AGENTS.md must be excluded from markdown linters**: AGENTS.md is auto-managed by lore and uses \`\*\` list markers and long lines that violate typical remark-lint rules (unordered-list-marker-style, maximum-line-length). When a project uses remark with \`--frail\` (warnings become errors), AGENTS.md will fail CI. Fix: add \`AGENTS.md\` to \`.remarkignore\`. This applies to any lore-managed project with markdown linting.

<!-- lore:019cc40e-e56e-71e9-bc5d-545f97df732b -->
* **Consola prompt cancel returns truthy Symbol, not false**: When a user cancels a \`consola\` / \`@clack/prompts\` confirmation prompt (Ctrl+C), the return value is \`Symbol(clack:cancel)\`, not \`false\`. Since Symbols are truthy in JavaScript, checking \`!confirmed\` will be \`false\` and the code falls through as if the user confirmed. Fix: use \`confirmed !== true\` (strict equality) instead of \`!confirmed\` to correctly handle cancel, false, and any other non-true values.

<!-- lore:019cc303-e397-75b9-9762-6f6ad108f50a -->
* **Zod z.coerce.number() converts null to 0 silently**: Zod gotchas in this codebase: (1) \`z.coerce.number()\` passes input through \`Number()\`, so \`null\` silently becomes \`0\`. Be aware if \`null\` vs \`0\` distinction matters. (2) Zod v4 \`.default({})\` short-circuits — it returns the default value without parsing through inner schema defaults. So \`.object({ enabled: z.boolean().default(true) }).default({})\` returns \`{}\`, not \`{ enabled: true }\`. Fix: provide fully-populated default objects. This affected nested config sections in src/config.ts during the v3→v4 upgrade.
<!-- End lore-managed section -->
2 changes: 1 addition & 1 deletion requirements-testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ asttokens
responses
pysocks
socksio
httpcore[http2]
httpcore[http2,asyncio]
setuptools
Brotli
docker
5 changes: 3 additions & 2 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
"pytest-asyncio",
"python-multipart",
"requests",
"anyio<4",
"anyio>=3,<5",
],
# There's an incompatibility between FastAPI's TestClient, which is
# actually Starlette's TestClient, which is actually httpx's Client.
Expand All @@ -132,6 +132,7 @@
# FastAPI versions we use older httpx which still supports the
# deprecated argument.
"<0.110.1": ["httpx<0.28.0"],
"<0.80": ["anyio<4"],
"py3.6": ["aiocontextvars"],
},
},
Expand Down Expand Up @@ -170,7 +171,7 @@
"httpx": {
"package": "httpx",
"deps": {
"*": ["anyio<4.0.0"],
"*": ["anyio>=3,<5"],
">=0.16,<0.17": ["pytest-httpx==0.10.0"],
">=0.17,<0.19": ["pytest-httpx==0.12.0"],
">=0.19,<0.21": ["pytest-httpx==0.14.0"],
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"configure_scope",
"continue_trace",
"flush",
"flush_async",
"get_baggage",
"get_client",
"get_global_scope",
Expand Down
9 changes: 9 additions & 0 deletions sentry_sdk/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def overload(x: "T") -> "T":
"configure_scope",
"continue_trace",
"flush",
"flush_async",
"get_baggage",
"get_client",
"get_global_scope",
Expand Down Expand Up @@ -349,6 +350,14 @@ def flush(
return get_client().flush(timeout=timeout, callback=callback)


@clientmethod
async def flush_async(
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
return await get_client().flush_async(timeout=timeout, callback=callback)


@scopemethod
def start_span(
**kwargs: "Any",
Expand Down
118 changes: 100 additions & 18 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from sentry_sdk.serializer import serialize
from sentry_sdk.tracing import trace
from sentry_sdk.tracing_utils import has_span_streaming_enabled
from sentry_sdk.transport import BaseHttpTransport, make_transport
from sentry_sdk.transport import HttpTransportCore, make_transport, AsyncHttpTransport
from sentry_sdk.consts import (
SPANDATA,
DEFAULT_MAX_VALUE_LENGTH,
Expand Down Expand Up @@ -253,6 +253,12 @@ def close(self, *args: "Any", **kwargs: "Any") -> None:
def flush(self, *args: "Any", **kwargs: "Any") -> None:
return None

async def close_async(self, *args: "Any", **kwargs: "Any") -> None:
return None

async def flush_async(self, *args: "Any", **kwargs: "Any") -> None:
return None

def __enter__(self) -> "BaseClient":
return self

Expand Down Expand Up @@ -474,7 +480,7 @@ def _record_lost_event(
or self.metrics_batcher
or self.span_batcher
or has_profiling_enabled(self.options)
or isinstance(self.transport, BaseHttpTransport)
or isinstance(self.transport, HttpTransportCore)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uwsgi thread check incorrectly triggered for async transport

Low Severity

The guard condition was changed from isinstance(self.transport, BaseHttpTransport) to isinstance(self.transport, HttpTransportCore). Since AsyncHttpTransport inherits from HttpTransportCore, the uwsgi thread-support warning now fires for async transports too. AsyncHttpTransport uses AsyncWorker (asyncio tasks), not background threads, so the uwsgi thread check is irrelevant and could produce a spurious warning.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

uWSGI thread check falsely triggers for async transport

Low Severity

The isinstance check changed from BaseHttpTransport to HttpTransportCore, which now also matches AsyncHttpTransport. Since AsyncHttpTransport uses AsyncWorker (asyncio tasks, not threads), triggering check_uwsgi_thread_support() for it is a false positive. The check should remain BaseHttpTransport to only match sync transports that actually spawn background threads via BackgroundWorker.

Fix in Cursor Fix in Web

):
# If we have anything on that could spawn a background thread, we
# need to check if it's safe to use them.
Expand Down Expand Up @@ -1001,6 +1007,28 @@ def get_integration(

return self.integrations.get(integration_name)

def _close_components(self) -> None:
"""Kill all client components in the correct order."""
self.session_flusher.kill()
if self.log_batcher is not None:
self.log_batcher.kill()
if self.metrics_batcher is not None:
self.metrics_batcher.kill()
if self.span_batcher is not None:
self.span_batcher.kill()
if self.monitor:
self.monitor.kill()

def _flush_components(self) -> None:
"""Flush all client components."""
self.session_flusher.flush()
if self.log_batcher is not None:
self.log_batcher.flush()
if self.metrics_batcher is not None:
self.metrics_batcher.flush()
if self.span_batcher is not None:
self.span_batcher.flush()

def close(
self,
timeout: "Optional[float]" = None,
Expand All @@ -1011,19 +1039,43 @@ def close(
semantics as :py:meth:`Client.flush`.
"""
if self.transport is not None:
if isinstance(self.transport, AsyncHttpTransport) and hasattr(
self.transport, "loop"
):
logger.debug(
"close() used with AsyncHttpTransport, aborting. Please use close_async() instead."
)
return
self.flush(timeout=timeout, callback=callback)
self.session_flusher.kill()
if self.log_batcher is not None:
self.log_batcher.kill()
if self.metrics_batcher is not None:
self.metrics_batcher.kill()
if self.span_batcher is not None:
self.span_batcher.kill()
if self.monitor:
self.monitor.kill()
self._close_components()
Comment on lines +1048 to +1056
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: Calling the synchronous client.close() with an AsyncHttpTransport queues session flushes via call_soon_threadsafe but immediately kills the worker, causing silent data loss.
Severity: HIGH

Suggested Fix

The synchronous close() method should not attempt to flush async components if it cannot guarantee their completion. Either remove the call to self._flush_components() within the if self._has_async_transport() block to avoid the false promise of flushing, or modify the logic to block and wait for the async flush operations to complete before proceeding to kill the transport.

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_sdk/client.py#L1048-L1056

Potential issue: When the synchronous `client.close()` method is called on a client
configured with an `AsyncHttpTransport`, it attempts to flush pending sessions by
calling `_flush_components()`. This queues session envelopes for transport using
`loop.call_soon_threadsafe()`, which returns immediately. The `close()` method then
proceeds to call `transport.kill()`, which cancels the async worker task responsible for
processing the queue. Due to this race condition, the newly queued session updates are
never processed and are silently lost before the worker is terminated.

self.transport.kill()
self.transport = None

async def close_async(
self,
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
"""
Asynchronously close the client and shut down the transport. Arguments have the same
semantics as :py:meth:`Client.flush_async`.
"""
if self.transport is not None:
if not (
isinstance(self.transport, AsyncHttpTransport)
and hasattr(self.transport, "loop")
):
logger.debug(
"close_async() used with non-async transport, aborting. Please use close() instead."
)
return
await self.flush_async(timeout=timeout, callback=callback)
self._close_components()
kill_task = self.transport.kill() # type: ignore
if kill_task is not None:
await kill_task
self.transport = None

def flush(
self,
timeout: "Optional[float]" = None,
Expand All @@ -1037,17 +1089,47 @@ def flush(
:param callback: Is invoked with the number of pending events and the configured timeout.
"""
if self.transport is not None:
if isinstance(self.transport, AsyncHttpTransport) and hasattr(
self.transport, "loop"
):
logger.debug(
"flush() used with AsyncHttpTransport, aborting. Please use flush_async() instead."
)
return
if timeout is None:
timeout = self.options["shutdown_timeout"]
self.session_flusher.flush()
if self.log_batcher is not None:
self.log_batcher.flush()
if self.metrics_batcher is not None:
self.metrics_batcher.flush()
if self.span_batcher is not None:
self.span_batcher.flush()
self._flush_components()

self.transport.flush(timeout=timeout, callback=callback)

async def flush_async(
self,
timeout: "Optional[float]" = None,
callback: "Optional[Callable[[int, float], None]]" = None,
) -> None:
"""
Asynchronously wait for the current events to be sent.

:param timeout: Wait for at most `timeout` seconds. If no `timeout` is provided, the `shutdown_timeout` option value is used.

:param callback: Is invoked with the number of pending events and the configured timeout.
"""
if self.transport is not None:
if not (
isinstance(self.transport, AsyncHttpTransport)
and hasattr(self.transport, "loop")
):
logger.debug(
"flush_async() used with non-async transport, aborting. Please use flush() instead."
)
return
if timeout is None:
timeout = self.options["shutdown_timeout"]
self._flush_components()
flush_task = self.transport.flush(timeout=timeout, callback=callback) # type: ignore
if flush_task is not None:
await flush_task

def __enter__(self) -> "_Client":
return self

Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class CompressionAlgo(Enum):
"transport_compression_algo": Optional[CompressionAlgo],
"transport_num_pools": Optional[int],
"transport_http2": Optional[bool],
"transport_async": Optional[bool],
"enable_logs": Optional[bool],
"before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]],
"enable_metrics": Optional[bool],
Expand Down
Loading
Loading