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
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 -->
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
128 changes: 109 additions & 19 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 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 @@
or self.metrics_batcher
or self.span_batcher
or has_profiling_enabled(self.options)
or isinstance(self.transport, BaseHttpTransport)
or isinstance(self.transport, HttpTransportCore)
):
# 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 @@

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,45 @@
semantics as :py:meth:`Client.flush`.
"""
if self.transport is not None:
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()
if isinstance(self.transport, AsyncHttpTransport) and hasattr(
self.transport, "loop"
):
logger.warning(
"close() used with AsyncHttpTransport. "
"Prefer close_async() for graceful async shutdown. "
"Performing synchronous best-effort cleanup."
)
else:
self.flush(timeout=timeout, callback=callback)
self._close_components()
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

Check warning on line 1073 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: code-review

close_async() silently leaves client unclosed when used with sync transport

When `close_async()` is called on a client with a sync transport, it logs only at DEBUG level and returns without closing the client. The transport remains active and resources are not released. This is problematic especially for `__aexit__` (line 1144-1145) which calls `close_async()` - using `async with client:` with a sync transport will silently leave the client unclosed, potentially causing resource leaks.

Check warning on line 1073 in sentry_sdk/client.py

View check run for this annotation

@sentry/warden / warden: find-bugs

close_async() silently returns without cleanup when used with non-async transport

When `close_async()` is called with a non-async (sync) transport, it logs a debug message and returns early without calling `_close_components()` or `transport.kill()`. This means components like session_flusher, log_batcher, metrics_batcher, span_batcher, and monitor are never killed, leaving background threads running. While the docstring says to use `close()` instead, returning silently without cleanup can cause resource leaks if the user mistakenly calls `close_async()`.
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,23 +1091,59 @@
: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.warning(
"flush() used with AsyncHttpTransport. 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

def __exit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None:
self.close()

async def __aenter__(self) -> "_Client":
return self

async def __aexit__(self, exc_type: "Any", exc_value: "Any", tb: "Any") -> None:
await self.close_async()


from typing import TYPE_CHECKING

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