From d47e85a9bddaf514775d33e4245a5b6968e5bc68 Mon Sep 17 00:00:00 2001 From: dataisland Date: Wed, 6 May 2026 15:54:45 -0700 Subject: [PATCH 01/11] fix(openai): guard against choices=None in OpenAI integration Some providers (e.g. OpenRouter) can return choices=None on upstream error responses. hasattr(response, 'choices') returns True even when the attribute is None, causing a TypeError when iterating. Add an explicit None check before iterating response.choices. --- sentry_sdk/integrations/openai.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 901fa403c8..cb7b21d10e 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -208,7 +208,7 @@ def _calculate_completions_token_usage( if streaming_message_responses is not None: for message in streaming_message_responses: output_tokens += count_tokens(message) - elif hasattr(response, "choices"): + elif hasattr(response, "choices") and response.choices is not None: for choice in response.choices: if hasattr(choice, "message") and hasattr(choice.message, "content"): output_tokens += count_tokens(choice.message.content) @@ -583,7 +583,7 @@ def _set_common_output_data( set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_MODEL, response.model) # Chat Completions API - if hasattr(response, "choices"): + if hasattr(response, "choices") and response.choices is not None: if should_send_default_pii() and integration.include_prompts: response_text = [ choice.message.model_dump() From d61981f2aa1edde749971a901c2b0c01d23adc84 Mon Sep 17 00:00:00 2001 From: dataisland Date: Wed, 6 May 2026 16:01:40 -0700 Subject: [PATCH 02/11] fix(openai): guard against choices=None in streaming chunk iterators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a streaming chunk has choices=None, the existing hasattr check passes but the subsequent iteration raises TypeError. Inside capture_internal_exceptions() this is silently suppressed, causing the usage capture on the next line to be skipped — leaving token usage and response text missing from the Sentry span. Add explicit None checks in both the sync and async streaming iterators. --- sentry_sdk/integrations/openai.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index cb7b21d10e..b3919d1a9d 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -839,7 +839,7 @@ def _wrap_synchronous_completions_chunk_iterator( span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, x.model) with capture_internal_exceptions(): - if hasattr(x, "choices"): + if hasattr(x, "choices") and x.choices is not None: choice_index = 0 for choice in x.choices: if hasattr(choice, "delta") and hasattr(choice.delta, "content"): @@ -901,7 +901,7 @@ async def _wrap_asynchronous_completions_chunk_iterator( span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, x.model) with capture_internal_exceptions(): - if hasattr(x, "choices"): + if hasattr(x, "choices") and x.choices is not None: choice_index = 0 for choice in x.choices: if hasattr(choice, "delta") and hasattr(choice.delta, "content"): From 76b4c9fa3bd905aab4485048cbb1124bcf66952b Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 10:01:27 +0200 Subject: [PATCH 03/11] fix(stdlib): Instrument response body read for chunked HTTP responses (#6202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP client span in the stdlib integration was finishing in `getresponse()`, which only waits for response headers, not the actual response. For chunked or large responses, the actual data transfer happens during `read()`, leaving that time uninstrumented. Defer span completion to `HTTPResponse.read()` for responses with a body (chunked or Content-Length > 0), with `HTTPResponse.close()` as a safety net for responses that are never read. ⚠️ Note that this means we might report some requests to be longer than they actually were, since in some cases we only close them once the GC gets to them (and `close()` is called). In a sense we're essentially flipping the current situation (where we report requests to be much shorter than they are, since they don't include the response part) -- but the current situation was incorrect for all spans, while this `close()` fallback should hopefully only kick in for edge cases. Responses with no body (Content-Length: 0, HEAD, 204, 304) still finish the span immediately in `getresponse()`. * Closes https://github.com/getsentry/sentry-python/issues/2277 * Closes https://linear.app/getsentry/issue/PY-159/sentry-does-not-fully-instrument-requests-library-requests --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Alex Alderman Webb --- sentry_sdk/integrations/stdlib.py | 77 ++++++++++++++++------ tests/integrations/stdlib/test_httplib.py | 79 +++++++++++++++++++++++ 2 files changed, 138 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index 5d8df43eb2..7573f8da7c 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -2,7 +2,7 @@ import subprocess import sys import platform -from http.client import HTTPConnection +from http.client import HTTPConnection, HTTPResponse import sentry_sdk from sentry_sdk.consts import OP, SPANDATA @@ -66,9 +66,22 @@ def add_python_runtime_context( return event +def _complete_span(span: "Union[Span, StreamedSpan]") -> None: + if isinstance(span, StreamedSpan): + with capture_internal_exceptions(): + add_http_request_source(span) + span.end() + else: + span.finish() + with capture_internal_exceptions(): + add_http_request_source(span) + + def _install_httplib() -> None: real_putrequest = HTTPConnection.putrequest real_getresponse = HTTPConnection.getresponse + real_read = HTTPResponse.read + real_close = HTTPResponse.close def putrequest( self: "HTTPConnection", method: str, url: str, *args: "Any", **kwargs: "Any" @@ -172,29 +185,57 @@ def getresponse(self: "HTTPConnection", *args: "Any", **kwargs: "Any") -> "Any": try: rv = real_getresponse(self, *args, **kwargs) + except BaseException: + _complete_span(span) + raise + + if isinstance(span, StreamedSpan): + status_code = int(rv.status) + span.status = "error" if status_code >= 400 else "ok" + span.set_attribute("http.response.status_code", status_code) + else: + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) + + # getresponse doesn't include actually reading the response body. This + # is done in read(). So if the metadata/headers suggest there's a body to + # read, don't finish the span just yet, but save it for ending it later. + has_body = rv.chunked or (rv.length is not None and rv.length > 0) + if has_body: + rv._sentrysdk_span = span # type: ignore[attr-defined] + else: + _complete_span(span) - if isinstance(span, StreamedSpan): - status_code = int(rv.status) - span.status = "error" if status_code >= 400 else "ok" - span.set_attribute("http.response.status_code", status_code) - else: - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) - finally: - if isinstance(span, StreamedSpan): - with capture_internal_exceptions(): - add_http_request_source(span) - span.end() - else: - span.finish() + return rv - with capture_internal_exceptions(): - add_http_request_source(span) + def read(self: "HTTPResponse", *args: "Any", **kwargs: "Any") -> "Any": + try: + return real_read(self, *args, **kwargs) + finally: + span = getattr(self, "_sentrysdk_span", None) + # read() might be called multiple times to consume a single body, + # so we can't just end the span when read() is done. Instead, + # try to figure out whether the response body has been fully read. + if span and (self.fp is None or self.closed): + self._sentrysdk_span = None # type: ignore[attr-defined] + _complete_span(span) + + def close(self: "HTTPResponse") -> None: + # We patch close() as a best effort fallback in case the span is not + # ended yet in getresponse() or read(). - return rv + try: + real_close(self) + finally: + span = getattr(self, "_sentrysdk_span", None) + if span is not None: + self._sentrysdk_span = None # type: ignore[attr-defined] + _complete_span(span) HTTPConnection.putrequest = putrequest # type: ignore[method-assign] HTTPConnection.getresponse = getresponse # type: ignore[method-assign] + HTTPResponse.read = read # type: ignore[method-assign] + HTTPResponse.close = close # type: ignore[assignment,method-assign] def _init_argument( diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 33aa95825d..589a8e8e97 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,6 +1,7 @@ import os import socket import datetime +import time from http.client import HTTPConnection, HTTPSConnection from http.server import BaseHTTPRequestHandler, HTTPServer from socket import SocketIO @@ -44,6 +45,37 @@ def create_mock_proxy_server(): PROXY_PORT = create_mock_proxy_server() +CHUNK_DELAY = 0.1 +NUM_CHUNKS = 3 + + +class ChunkedResponseHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header("Transfer-Encoding", "chunked") + self.end_headers() + for _ in range(NUM_CHUNKS): + chunk = b"x" * 100 + self.wfile.write(f"{len(chunk):x}\r\n".encode() + chunk + b"\r\n") + self.wfile.flush() + time.sleep(CHUNK_DELAY) + self.wfile.write(b"0\r\n\r\n") + + def log_message(self, *args): + pass + + +def create_chunked_server(): + port = get_free_port() + server = HTTPServer(("localhost", port), ChunkedResponseHandler) + thread = Thread(target=server.serve_forever) + thread.daemon = True + thread.start() + return port + + +CHUNKED_PORT = create_chunked_server() + def test_crumb_capture(sentry_init, capture_events): sentry_init(integrations=[StdlibIntegration()]) @@ -1161,3 +1193,50 @@ def test_proxy_http_tunnel( assert span["data"][SPANDATA.HTTP_METHOD] == "GET" assert span["data"][SPANDATA.NETWORK_PEER_ADDRESS] == "localhost" assert span["data"][SPANDATA.NETWORK_PEER_PORT] == PROXY_PORT + + +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_chunked_response_span_covers_body_read( + sentry_init, + capture_events, + capture_items, + span_streaming, +): + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + + min_expected_duration = CHUNK_DELAY * NUM_CHUNKS + + if span_streaming: + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="custom parent"): + conn = HTTPConnection("localhost", CHUNKED_PORT) + conn.request("GET", "/chunked") + response = conn.getresponse() + response.read() + + sentry_sdk.flush() + http_span, parent_span = [item.payload for item in items] + + duration = http_span["end_timestamp"] - http_span["start_timestamp"] + assert duration >= min_expected_duration + else: + events = capture_events() + + with start_transaction(name="test_chunked"): + conn = HTTPConnection("localhost", CHUNKED_PORT) + conn.request("GET", "/chunked") + response = conn.getresponse() + response.read() + + (event,) = events + (span,) = event["spans"] + + fmt = "%Y-%m-%dT%H:%M:%S.%fZ" + start = datetime.datetime.strptime(span["start_timestamp"], fmt) + end = datetime.datetime.strptime(span["timestamp"], fmt) + duration = (end - start).total_seconds() + assert duration >= min_expected_duration From 0e24e416870ec2ecd1979aa6d8d1e4386324ec3c Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Thu, 7 May 2026 13:08:38 +0200 Subject: [PATCH 04/11] fix: Handle mypy 2.0 related failures (#6218) --- sentry_sdk/_compat.py | 2 +- sentry_sdk/client.py | 8 +- sentry_sdk/crons/api.py | 2 +- sentry_sdk/hub.py | 2 +- sentry_sdk/integrations/_wsgi_common.py | 7 +- .../integrations/cloud_resource_context.py | 18 +-- sentry_sdk/integrations/django/__init__.py | 6 +- sentry_sdk/integrations/django/asgi.py | 2 +- sentry_sdk/integrations/dramatiq.py | 2 +- sentry_sdk/integrations/gnu_backtrace.py | 4 +- sentry_sdk/integrations/grpc/__init__.py | 2 +- sentry_sdk/integrations/mcp.py | 6 +- .../integrations/openai_agents/__init__.py | 114 ++++++++++-------- .../integrations/opentelemetry/integration.py | 2 + sentry_sdk/integrations/sanic.py | 2 +- sentry_sdk/integrations/starlette.py | 2 +- sentry_sdk/scope.py | 6 +- sentry_sdk/serializer.py | 4 +- sentry_sdk/tracing.py | 4 +- 19 files changed, 103 insertions(+), 92 deletions(-) diff --git a/sentry_sdk/_compat.py b/sentry_sdk/_compat.py index dcb590fcfa..16f5c99150 100644 --- a/sentry_sdk/_compat.py +++ b/sentry_sdk/_compat.py @@ -54,7 +54,7 @@ def enabled(option: str) -> bool: except Exception: pass - return value and str(value).lower() not in FALSE_VALUES + return value and str(value).lower() not in FALSE_VALUES # type: ignore[return-value] # When `threads` is passed in as a uwsgi option, # `enable-threads` is implied on. diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 9f795d2489..d0b93e3bb1 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -864,7 +864,7 @@ def capture_event( :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ - hint: "Hint" = dict(hint or ()) + hint = dict(hint or ()) if not self._should_capture(event, hint, scope): return None @@ -948,7 +948,7 @@ def _capture_telemetry( if ty == "log": before_send = get_before_send_log(self.options) elif ty == "metric": - before_send = get_before_send_metric(self.options) # type: ignore + before_send = get_before_send_metric(self.options) if before_send is not None: telemetry = before_send(telemetry, {}) # type: ignore @@ -960,9 +960,9 @@ def _capture_telemetry( if ty == "log": batcher = self.log_batcher elif ty == "metric": - batcher = self.metrics_batcher # type: ignore + batcher = self.metrics_batcher elif ty == "span": - batcher = self.span_batcher # type: ignore + batcher = self.span_batcher if batcher is not None: batcher.add(telemetry) # type: ignore diff --git a/sentry_sdk/crons/api.py b/sentry_sdk/crons/api.py index 5b7bdc2480..d9a061902b 100644 --- a/sentry_sdk/crons/api.py +++ b/sentry_sdk/crons/api.py @@ -18,7 +18,7 @@ def _create_check_in_event( monitor_config: "Optional[MonitorConfig]" = None, ) -> "Event": options = sentry_sdk.get_client().options - check_in_id: str = check_in_id or uuid.uuid4().hex + check_in_id = check_in_id or uuid.uuid4().hex check_in: "Event" = { "type": "check_in", diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 0e5d7df9f9..b17444d06e 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -146,7 +146,7 @@ def __init__( scope = get_isolation_scope().fork() current_scope = get_current_scope().fork() else: - client = client_or_hub # type: ignore + client = client_or_hub get_global_scope().set_client(client) if scope is None: # so there is no Hub cloning going on diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index 4208955620..433c437d1a 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -9,8 +9,11 @@ try: from django.http.request import RawPostDataException + + _RAW_DATA_EXCEPTIONS = (RawPostDataException, ValueError) except ImportError: RawPostDataException = None + _RAW_DATA_EXCEPTIONS = (ValueError,) from typing import TYPE_CHECKING @@ -110,7 +113,7 @@ def extract_into_event(self, event: "Event") -> None: raw_data = None try: raw_data = self.raw_data() - except (RawPostDataException, ValueError): + except _RAW_DATA_EXCEPTIONS: # If DjangoRestFramework is used it already read the body for us # so reading it here will fail. We can ignore this. pass @@ -175,7 +178,7 @@ def json(self) -> "Optional[Any]": try: raw_data = self.raw_data() - except (RawPostDataException, ValueError): + except _RAW_DATA_EXCEPTIONS: # The body might have already been read, in which case this will # fail raw_data = None diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py index 09d55ac119..0fb49b7c32 100644 --- a/sentry_sdk/integrations/cloud_resource_context.py +++ b/sentry_sdk/integrations/cloud_resource_context.py @@ -176,6 +176,7 @@ def _get_gcp_context(cls) -> "Dict[str, str]": "cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE, } + gcp_metadata = cls.gcp_metadata try: if cls.gcp_metadata is None: r = cls.http.request( @@ -187,30 +188,29 @@ def _get_gcp_context(cls) -> "Dict[str, str]": if r.status != 200: return ctx - cls.gcp_metadata = json.loads(r.data.decode("utf-8")) + gcp_metadata = json.loads(r.data.decode("utf-8")) + cls.gcp_metadata = gcp_metadata try: - ctx["cloud.account.id"] = cls.gcp_metadata["project"]["projectId"] + ctx["cloud.account.id"] = gcp_metadata["project"]["projectId"] except Exception: pass try: - ctx["cloud.availability_zone"] = cls.gcp_metadata["instance"][ - "zone" - ].split("/")[-1] + ctx["cloud.availability_zone"] = gcp_metadata["instance"]["zone"].split( + "/" + )[-1] except Exception: pass try: # only populated in google cloud run - ctx["cloud.region"] = cls.gcp_metadata["instance"]["region"].split("/")[ - -1 - ] + ctx["cloud.region"] = gcp_metadata["instance"]["region"].split("/")[-1] except Exception: pass try: - ctx["host.id"] = cls.gcp_metadata["instance"]["id"] + ctx["host.id"] = gcp_metadata["instance"]["id"] except Exception: pass diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 2595c33ea8..b25944c8ea 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -120,9 +120,9 @@ class DjangoIntegration(Integration): origin_db = f"auto.db.{identifier}" transaction_style = "" - middleware_spans = None - signals_spans = None - cache_spans = None + middleware_spans: "Optional[bool]" = None + signals_spans: "Optional[bool]" = None + cache_spans: "Optional[bool]" = None signals_denylist: "list[signals.Signal]" = [] def __init__( diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index f3aff113d6..3e15bf592e 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -44,7 +44,7 @@ iscoroutinefunction = inspect.iscoroutinefunction markcoroutinefunction = inspect.markcoroutinefunction else: - iscoroutinefunction = asyncio.iscoroutinefunction # type: ignore[assignment] + iscoroutinefunction = asyncio.iscoroutinefunction def markcoroutinefunction(func: "_F") -> "_F": func._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore diff --git a/sentry_sdk/integrations/dramatiq.py b/sentry_sdk/integrations/dramatiq.py index f954d4fb98..db02234b00 100644 --- a/sentry_sdk/integrations/dramatiq.py +++ b/sentry_sdk/integrations/dramatiq.py @@ -70,7 +70,7 @@ def sentry_patched_broker__init__( # RedisBroker does not. if len(args) == 1: middleware = args[0] - args = [] # type: ignore + args = () else: middleware = None diff --git a/sentry_sdk/integrations/gnu_backtrace.py b/sentry_sdk/integrations/gnu_backtrace.py index dbadf42088..20b8eddd8b 100644 --- a/sentry_sdk/integrations/gnu_backtrace.py +++ b/sentry_sdk/integrations/gnu_backtrace.py @@ -16,14 +16,14 @@ FUNCTION_RE = r"[^@]+?" HEX_ADDRESS = r"\s+@\s+0x[0-9a-fA-F]+" -FRAME_RE = r""" +_FRAME_RE_PATTERN = r""" ^(?P\d+)\.\s+(?P{FUNCTION_RE}){HEX_ADDRESS}(?:\s+in\s+(?P.+))?$ """.format( FUNCTION_RE=FUNCTION_RE, HEX_ADDRESS=HEX_ADDRESS, ) -FRAME_RE = re.compile(FRAME_RE, re.MULTILINE | re.VERBOSE) +FRAME_RE = re.compile(_FRAME_RE_PATTERN, re.MULTILINE | re.VERBOSE) class GnuBacktraceIntegration(Integration): diff --git a/sentry_sdk/integrations/grpc/__init__.py b/sentry_sdk/integrations/grpc/__init__.py index a41631c37e..0912a5769a 100644 --- a/sentry_sdk/integrations/grpc/__init__.py +++ b/sentry_sdk/integrations/grpc/__init__.py @@ -152,7 +152,7 @@ def patched_aio_server( # type: ignore **kwargs: "P.kwargs", ) -> "Server": server_interceptor = AsyncServerInterceptor() - interceptors: "Sequence[grpc.ServerInterceptor]" = [ + interceptors = [ server_interceptor, *(interceptors or []), ] diff --git a/sentry_sdk/integrations/mcp.py b/sentry_sdk/integrations/mcp.py index 1c70fefea5..b29d43d9ea 100644 --- a/sentry_sdk/integrations/mcp.py +++ b/sentry_sdk/integrations/mcp.py @@ -495,7 +495,7 @@ async def _handler_wrapper( uri = original_kwargs.get("uri") protocol = None - if hasattr(uri, "scheme"): + if uri is not None and hasattr(uri, "scheme"): protocol = uri.scheme elif handler_name and "://" in handler_name: protocol = handler_name.split("://")[0] @@ -638,7 +638,7 @@ def _patch_fastmcp() -> None: This function patches the _get_prompt_mcp and _read_resource_mcp methods to add instrumentation for those handlers. """ - if hasattr(FastMCP, "_get_prompt_mcp"): + if FastMCP is not None and hasattr(FastMCP, "_get_prompt_mcp"): original_get_prompt_mcp = FastMCP._get_prompt_mcp @wraps(original_get_prompt_mcp) @@ -655,7 +655,7 @@ async def patched_get_prompt_mcp( FastMCP._get_prompt_mcp = patched_get_prompt_mcp - if hasattr(FastMCP, "_read_resource_mcp"): + if FastMCP is not None and hasattr(FastMCP, "_read_resource_mcp"): original_read_resource_mcp = FastMCP._read_resource_mcp @wraps(original_read_resource_mcp) diff --git a/sentry_sdk/integrations/openai_agents/__init__.py b/sentry_sdk/integrations/openai_agents/__init__.py index 1268ef3755..d8c0e325e4 100644 --- a/sentry_sdk/integrations/openai_agents/__init__.py +++ b/sentry_sdk/integrations/openai_agents/__init__.py @@ -99,72 +99,80 @@ def setup_once() -> None: 0, 8, ): - - @wraps(run_loop.get_all_tools) - async def new_wrapped_get_all_tools( - agent: "agents.Agent", - context_wrapper: "agents.RunContextWrapper", - ) -> "list[agents.Tool]": - return await _get_all_tools( - run_loop.get_all_tools, agent, context_wrapper + if run_loop is not None: + + @wraps(run_loop.get_all_tools) + async def new_wrapped_get_all_tools( + agent: "agents.Agent", + context_wrapper: "agents.RunContextWrapper", + ) -> "list[agents.Tool]": + return await _get_all_tools( + run_loop.get_all_tools, agent, context_wrapper + ) + + agents.run.get_all_tools = new_wrapped_get_all_tools + + @wraps(run_loop.run_single_turn) + async def new_wrapped_run_single_turn( + *args: "Any", **kwargs: "Any" + ) -> "SingleStepResult": + return await _run_single_turn( + run_loop.run_single_turn, *args, **kwargs + ) + + agents.run.run_single_turn = new_wrapped_run_single_turn + + @wraps(run_loop.run_single_turn_streamed) + async def new_wrapped_run_single_turn_streamed( + *args: "Any", **kwargs: "Any" + ) -> "SingleStepResult": + return await _run_single_turn_streamed( + run_loop.run_single_turn_streamed, *args, **kwargs + ) + + agents.run.run_single_turn_streamed = ( + new_wrapped_run_single_turn_streamed ) - agents.run.get_all_tools = new_wrapped_get_all_tools - - @wraps(turn_preparation.get_model) - def new_wrapped_get_model( - agent: "agents.Agent", run_config: "agents.RunConfig" - ) -> "agents.Model": - return _get_model(turn_preparation.get_model, agent, run_config) - - agents.run_internal.run_loop.get_model = new_wrapped_get_model - - @wraps(run_loop.run_single_turn) - async def new_wrapped_run_single_turn( - *args: "Any", **kwargs: "Any" - ) -> "SingleStepResult": - return await _run_single_turn(run_loop.run_single_turn, *args, **kwargs) + if turn_preparation is not None: - agents.run.run_single_turn = new_wrapped_run_single_turn + @wraps(turn_preparation.get_model) + def new_wrapped_get_model( + agent: "agents.Agent", run_config: "agents.RunConfig" + ) -> "agents.Model": + return _get_model(turn_preparation.get_model, agent, run_config) - @wraps(run_loop.run_single_turn_streamed) - async def new_wrapped_run_single_turn_streamed( - *args: "Any", **kwargs: "Any" - ) -> "SingleStepResult": - return await _run_single_turn_streamed( - run_loop.run_single_turn_streamed, *args, **kwargs - ) + agents.run_internal.run_loop.get_model = new_wrapped_get_model - agents.run.run_single_turn_streamed = new_wrapped_run_single_turn_streamed + if turn_resolution is not None: + original_execute_handoffs = turn_resolution.execute_handoffs - original_execute_handoffs = turn_resolution.execute_handoffs + @wraps(original_execute_handoffs) + async def new_wrapped_execute_handoffs( + *args: "Any", **kwargs: "Any" + ) -> "SingleStepResult": + return await _execute_handoffs( + original_execute_handoffs, *args, **kwargs + ) - @wraps(original_execute_handoffs) - async def new_wrapped_execute_handoffs( - *args: "Any", **kwargs: "Any" - ) -> "SingleStepResult": - return await _execute_handoffs( - original_execute_handoffs, *args, **kwargs + agents.run_internal.turn_resolution.execute_handoffs = ( + new_wrapped_execute_handoffs ) - agents.run_internal.turn_resolution.execute_handoffs = ( - new_wrapped_execute_handoffs - ) + original_execute_final_output = turn_resolution.execute_final_output - original_execute_final_output = turn_resolution.execute_final_output + @wraps(turn_resolution.execute_final_output) + async def new_wrapped_final_output( + *args: "Any", **kwargs: "Any" + ) -> "SingleStepResult": + return await _execute_final_output( + original_execute_final_output, *args, **kwargs + ) - @wraps(turn_resolution.execute_final_output) - async def new_wrapped_final_output( - *args: "Any", **kwargs: "Any" - ) -> "SingleStepResult": - return await _execute_final_output( - original_execute_final_output, *args, **kwargs + agents.run_internal.turn_resolution.execute_final_output = ( + new_wrapped_final_output ) - agents.run_internal.turn_resolution.execute_final_output = ( - new_wrapped_final_output - ) - return original_get_all_tools = AgentRunner._get_all_tools diff --git a/sentry_sdk/integrations/opentelemetry/integration.py b/sentry_sdk/integrations/opentelemetry/integration.py index 83588a2b38..ac5347cb2f 100644 --- a/sentry_sdk/integrations/opentelemetry/integration.py +++ b/sentry_sdk/integrations/opentelemetry/integration.py @@ -52,4 +52,6 @@ def _setup_sentry_tracing() -> None: def _setup_instrumentors() -> None: for instrumentor, kwargs in CONFIGURABLE_INSTRUMENTATIONS.items(): + if instrumentor is None: + continue instrumentor().instrument(**kwargs) diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 9199b76eba..0b1dc95c44 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -58,7 +58,7 @@ class SanicIntegration(Integration): identifier = "sanic" origin = f"auto.http.{identifier}" - version = None + version: "Optional[tuple[int, ...]]" = None def __init__( self, unsampled_statuses: "Optional[Container[int]]" = frozenset({404}) diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 0018d87b09..4371ed4f58 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -475,7 +475,7 @@ def _is_async_callable(obj: "Any") -> bool: obj = obj.func return iscoroutinefunction(obj) or ( - callable(obj) and iscoroutinefunction(obj.__call__) + callable(obj) and iscoroutinefunction(obj.__call__) # type: ignore[operator] ) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 878d61c0a1..9c9b0c152f 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -518,7 +518,7 @@ def _load_trace_data_from_env(self) -> "Optional[Dict[str, str]]": Load Sentry trace id and baggage from environment variables. Can be disabled by setting SENTRY_USE_ENVIRONMENT to "false". """ - incoming_trace_information = None + incoming_trace_information: "Optional[Dict[str, str]]" = None sentry_use_environment = ( os.environ.get("SENTRY_USE_ENVIRONMENT") or "" @@ -1065,12 +1065,12 @@ def add_breadcrumb( before_breadcrumb = client.options.get("before_breadcrumb") max_breadcrumbs = client.options.get("max_breadcrumbs", DEFAULT_MAX_BREADCRUMBS) - crumb: "Breadcrumb" = dict(crumb or ()) + crumb = dict(crumb or ()) crumb.update(kwargs) if not crumb: return - hint: "Hint" = dict(hint or ()) + hint = dict(hint or ()) if crumb.get("timestamp") is None: crumb["timestamp"] = datetime.now(timezone.utc) diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index 9725d3ab53..5f6e75cc67 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -338,9 +338,9 @@ def _serialize_node_impl( ): rv_list = [] - for i, v in enumerate(obj): + for i, v in enumerate(obj): # type: ignore[arg-type] if remaining_breadth is not None and i >= remaining_breadth: - _annotate(len=len(obj)) + _annotate(len=len(obj)) # type: ignore[arg-type] break rv_list.append( diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index 7f2baba0c9..c0c0fa9bde 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -965,9 +965,7 @@ def finish( # For backwards compatibility, we must handle the case where `scope` # or `hub` could both either be a `Scope` or a `Hub`. - scope: "Optional[sentry_sdk.Scope]" = self._get_scope_from_finish_args( - scope, hub - ) + scope = self._get_scope_from_finish_args(scope, hub) scope = scope or self.scope or sentry_sdk.get_current_scope() client = sentry_sdk.get_client() From 5981bd64d49803e9e5b1687f12eb30cedfaa172f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 13:20:38 +0200 Subject: [PATCH 05/11] ref(tests): Replace custom `envelopes_to_x` helpers with `capture_items` (#6233) We had multiple `envelopes_to_x` helpers around in tests for unwrapping metrics, logs, and spans. Replace them all with `capture_items`, which has the added benefit of being more true to the actual payload leaving the SDK, unlike `envelopes_to_x`, which used to modify some fields (setting them to `None` if they were not present, for instance). Co-authored-by: Claude Opus 4.6 --- tests/conftest.py | 2 +- tests/integrations/logging/test_logging.py | 69 +++--- tests/integrations/loguru/test_loguru.py | 112 ++++----- tests/test_attributes.py | 38 ++- tests/test_logs.py | 174 ++++++-------- tests/test_metrics.py | 102 +++----- tests/tracing/test_span_streaming.py | 260 ++++++++++----------- 7 files changed, 332 insertions(+), 425 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 8e7a2ffec6..ac54207664 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -358,7 +358,7 @@ def append_envelope(envelope): if types and item.type not in types: continue - if item.type in ("metric", "log", "span"): + if item.type in ("trace_metric", "log", "span"): for i in item.payload.json["items"]: t = {k: v for k, v in i.items() if k != "attributes"} t["attributes"] = { diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 12dbacc9ec..6b54baa0c5 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -12,7 +12,6 @@ unignore_logger, unignore_logger_for_sentry_logs, ) -from tests.test_logs import envelopes_to_logs other_logger = logging.getLogger("testfoo") logger = logging.getLogger(__name__) @@ -271,12 +270,10 @@ def test_ignore_logger_wildcard(sentry_init, capture_events, request): assert event["logentry"]["formatted"] == "hi" -def test_ignore_logger_does_not_affect_sentry_logs( - sentry_init, capture_envelopes, request -): +def test_ignore_logger_does_not_affect_sentry_logs(sentry_init, capture_items, request): """ignore_logger should suppress events/breadcrumbs but not Sentry Logs.""" sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") ignore_logger("testfoo") request.addfinalizer(lambda: unignore_logger("testfoo")) @@ -284,15 +281,18 @@ def test_ignore_logger_does_not_affect_sentry_logs( other_logger.error("hi") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 assert logs[0]["body"] == "hi" -def test_ignore_logger_for_sentry_logs(sentry_init, capture_envelopes, request): +def test_ignore_logger_for_sentry_logs( + sentry_init, capture_envelopes, capture_items, request +): """ignore_logger_for_sentry_logs should suppress Sentry Logs but not events.""" sentry_init(enable_logs=True) envelopes = capture_envelopes() + items = capture_items("log") ignore_logger_for_sentry_logs("testfoo") request.addfinalizer(lambda: unignore_logger_for_sentry_logs("testfoo")) @@ -305,7 +305,7 @@ def test_ignore_logger_for_sentry_logs(sentry_init, capture_envelopes, request): assert len(event_envelopes) == 1 # But no Sentry Logs - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 0 @@ -347,18 +347,18 @@ def test_logging_dictionary_args(sentry_init, capture_events): assert event["logentry"]["params"] == {"foo": "bar", "bar": "baz"} -def test_sentry_logs_warning(sentry_init, capture_envelopes): +def test_sentry_logs_warning(sentry_init, capture_items): """ The python logger module should create 'warn' sentry logs if the flag is on. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.warning("this is %s a template %s", "1", "2") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attrs = logs[0]["attributes"] assert attrs["sentry.message.template"] == "this is %s a template %s" assert "code.file.path" in attrs @@ -368,8 +368,8 @@ def test_sentry_logs_warning(sentry_init, capture_envelopes): assert attrs["sentry.message.parameter.0"] == "1" assert attrs["sentry.message.parameter.1"] == "2" assert attrs["sentry.origin"] == "auto.log.stdlib" - assert logs[0]["severity_number"] == 13 - assert logs[0]["severity_text"] == "warn" + assert logs[0]["attributes"]["sentry.severity_number"] == 13 + assert logs[0]["attributes"]["sentry.severity_text"] == "warn" def test_sentry_logs_debug(sentry_init, capture_envelopes): @@ -404,12 +404,13 @@ def test_no_log_infinite_loop(sentry_init, capture_envelopes): assert len(envelopes) == 1 -def test_logging_errors(sentry_init, capture_envelopes): +def test_logging_errors(sentry_init, capture_envelopes, capture_items): """ The python logger module should be able to log errors without erroring """ sentry_init(enable_logs=True) envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.error(Exception("test exc 1")) @@ -421,13 +422,13 @@ def test_logging_errors(sentry_init, capture_envelopes): error_event_2 = envelopes[1].items[0].payload.json assert error_event_2["level"] == "error" - logs = envelopes_to_logs(envelopes) - assert logs[0]["severity_text"] == "error" + logs = [item.payload for item in items] + assert logs[0]["attributes"]["sentry.severity_text"] == "error" assert "sentry.message.template" not in logs[0]["attributes"] assert "sentry.message.parameter.0" not in logs[0]["attributes"] assert "code.line.number" in logs[0]["attributes"] - assert logs[1]["severity_text"] == "error" + assert logs[1]["attributes"]["sentry.severity_text"] == "error" assert logs[1]["attributes"]["sentry.message.template"] == "error is %s" assert logs[1]["attributes"]["sentry.message.parameter.0"] in ( "Exception('test exc 2')", @@ -438,7 +439,7 @@ def test_logging_errors(sentry_init, capture_envelopes): assert len(logs) == 2 -def test_log_strips_project_root(sentry_init, capture_envelopes): +def test_log_strips_project_root(sentry_init, capture_items): """ The python logger should strip project roots from the log record path """ @@ -446,7 +447,7 @@ def test_log_strips_project_root(sentry_init, capture_envelopes): enable_logs=True, project_root="/custom/test", ) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.handle( @@ -462,18 +463,18 @@ def test_log_strips_project_root(sentry_init, capture_envelopes): ) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 attrs = logs[0]["attributes"] assert attrs["code.file.path"] == "blah/path.py" -def test_logger_with_all_attributes(sentry_init, capture_envelopes): +def test_logger_with_all_attributes(sentry_init, capture_items): """ The python logger should be able to log all attributes, including extra data. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.warning( @@ -483,7 +484,7 @@ def test_logger_with_all_attributes(sentry_init, capture_envelopes): ) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert "span_id" in logs[0] assert logs[0]["span_id"] is None @@ -544,12 +545,12 @@ def test_logger_with_all_attributes(sentry_init, capture_envelopes): } -def test_sentry_logs_named_parameters(sentry_init, capture_envelopes): +def test_sentry_logs_named_parameters(sentry_init, capture_items): """ The python logger module should capture named parameters from dictionary arguments in Sentry logs. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.info( @@ -564,7 +565,7 @@ def test_sentry_logs_named_parameters(sentry_init, capture_envelopes): ) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 attrs = logs[0]["attributes"] @@ -585,16 +586,16 @@ def test_sentry_logs_named_parameters(sentry_init, capture_envelopes): # Check other standard attributes assert attrs["logger.name"] == "test-logger" assert attrs["sentry.origin"] == "auto.log.stdlib" - assert logs[0]["severity_number"] == 9 # info level - assert logs[0]["severity_text"] == "info" + assert logs[0]["attributes"]["sentry.severity_number"] == 9 # info level + assert logs[0]["attributes"]["sentry.severity_text"] == "info" -def test_sentry_logs_named_parameters_complex_values(sentry_init, capture_envelopes): +def test_sentry_logs_named_parameters_complex_values(sentry_init, capture_items): """ The python logger module should handle complex values in named parameters using safe_repr. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") complex_object = {"nested": {"data": [1, 2, 3]}, "tuple": (4, 5, 6)} @@ -607,7 +608,7 @@ def test_sentry_logs_named_parameters_complex_values(sentry_init, capture_envelo ) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 attrs = logs[0]["attributes"] @@ -623,18 +624,18 @@ def test_sentry_logs_named_parameters_complex_values(sentry_init, capture_envelo assert "data" in complex_param -def test_sentry_logs_no_parameters_no_template(sentry_init, capture_envelopes): +def test_sentry_logs_no_parameters_no_template(sentry_init, capture_items): """ There shouldn't be a template if there are no parameters. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") python_logger = logging.Logger("test-logger") python_logger.warning("Warning about something without any parameters.") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 diff --git a/tests/integrations/loguru/test_loguru.py b/tests/integrations/loguru/test_loguru.py index 6fdc0cb1aa..00896f2d33 100644 --- a/tests/integrations/loguru/test_loguru.py +++ b/tests/integrations/loguru/test_loguru.py @@ -8,7 +8,6 @@ import sentry_sdk from sentry_sdk.consts import VERSION from sentry_sdk.integrations.loguru import LoguruIntegration, LoggingLevels -from tests.test_logs import envelopes_to_logs logger.remove(0) # don't print to console @@ -136,18 +135,18 @@ def test_event_format(sentry_init, capture_events, uninstall_integration, reques def test_sentry_logs_warning( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.warning("this is {} a {}", "just", "template") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attrs = logs[0]["attributes"] assert "code.file.path" in attrs @@ -155,8 +154,8 @@ def test_sentry_logs_warning( assert attrs["logger.name"] == "tests.integrations.loguru.test_loguru" assert attrs["sentry.environment"] == "production" assert attrs["sentry.origin"] == "auto.log.loguru" - assert logs[0]["severity_number"] == 13 - assert logs[0]["severity_text"] == "warn" + assert logs[0]["attributes"]["sentry.severity_number"] == 13 + assert logs[0]["attributes"]["sentry.severity_text"] == "warn" def test_sentry_logs_debug( @@ -174,9 +173,7 @@ def test_sentry_logs_debug( assert len(envelopes) == 0 -def test_sentry_log_levels( - sentry_init, capture_envelopes, uninstall_integration, request -): +def test_sentry_log_levels(sentry_init, capture_items, uninstall_integration, request): uninstall_integration("loguru") request.addfinalizer(logger.remove) @@ -184,7 +181,7 @@ def test_sentry_log_levels( integrations=[LoguruIntegration(sentry_logs_level=LoggingLevels.SUCCESS)], enable_logs=True, ) - envelopes = capture_envelopes() + items = capture_items("log") logger.trace("this is a log") logger.debug("this is a log") @@ -195,21 +192,21 @@ def test_sentry_log_levels( logger.critical("this is a log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 4 - assert logs[0]["severity_number"] == 11 - assert logs[0]["severity_text"] == "info" - assert logs[1]["severity_number"] == 13 - assert logs[1]["severity_text"] == "warn" - assert logs[2]["severity_number"] == 17 - assert logs[2]["severity_text"] == "error" - assert logs[3]["severity_number"] == 21 - assert logs[3]["severity_text"] == "fatal" + assert logs[0]["attributes"]["sentry.severity_number"] == 11 + assert logs[0]["attributes"]["sentry.severity_text"] == "info" + assert logs[1]["attributes"]["sentry.severity_number"] == 13 + assert logs[1]["attributes"]["sentry.severity_text"] == "warn" + assert logs[2]["attributes"]["sentry.severity_number"] == 17 + assert logs[2]["attributes"]["sentry.severity_text"] == "error" + assert logs[3]["attributes"]["sentry.severity_number"] == 21 + assert logs[3]["attributes"]["sentry.severity_text"] == "fatal" def test_disable_loguru_logs( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) @@ -218,7 +215,7 @@ def test_disable_loguru_logs( integrations=[LoguruIntegration(sentry_logs_level=None)], enable_logs=True, ) - envelopes = capture_envelopes() + items = capture_items("log") logger.trace("this is a log") logger.debug("this is a log") @@ -229,12 +226,12 @@ def test_disable_loguru_logs( logger.critical("this is a log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 0 def test_disable_sentry_logs( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) @@ -242,7 +239,7 @@ def test_disable_sentry_logs( sentry_init( _experiments={"enable_logs": False}, ) - envelopes = capture_envelopes() + items = capture_items("log") logger.trace("this is a log") logger.debug("this is a log") @@ -253,7 +250,7 @@ def test_disable_sentry_logs( logger.critical("this is a log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 0 @@ -279,13 +276,16 @@ def test_no_log_infinite_loop( assert len(envelopes) == 1 -def test_logging_errors(sentry_init, capture_envelopes, uninstall_integration, request): +def test_logging_errors( + sentry_init, capture_envelopes, capture_items, uninstall_integration, request +): """We're able to log errors without erroring.""" uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) envelopes = capture_envelopes() + items = capture_items("log") logger.error(Exception("test exc 1")) logger.error("error is %s", Exception("test exc 2")) @@ -296,18 +296,18 @@ def test_logging_errors(sentry_init, capture_envelopes, uninstall_integration, r error_event_2 = envelopes[1].items[0].payload.json assert error_event_2["level"] == "error" - logs = envelopes_to_logs(envelopes) - assert logs[0]["severity_text"] == "error" + logs = [item.payload for item in items] + assert logs[0]["attributes"]["sentry.severity_text"] == "error" assert "code.line.number" in logs[0]["attributes"] - assert logs[1]["severity_text"] == "error" + assert logs[1]["attributes"]["sentry.severity_text"] == "error" assert "code.line.number" in logs[1]["attributes"] assert len(logs) == 2 def test_log_strips_project_root( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) @@ -316,7 +316,7 @@ def test_log_strips_project_root( enable_logs=True, project_root="/custom/test", ) - envelopes = capture_envelopes() + items = capture_items("log") class FakeMessage: def __init__(self, *args, **kwargs): @@ -349,14 +349,14 @@ def record(self, val): sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 attrs = logs[0]["attributes"] assert attrs["code.file.path"] == "blah/path.py" def test_log_keeps_full_path_if_not_in_project_root( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) @@ -365,7 +365,7 @@ def test_log_keeps_full_path_if_not_in_project_root( enable_logs=True, project_root="/custom/test", ) - envelopes = capture_envelopes() + items = capture_items("log") class FakeMessage: def __init__(self, *args, **kwargs): @@ -398,25 +398,25 @@ def record(self, val): sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 attrs = logs[0]["attributes"] assert attrs["code.file.path"] == "/blah/path.py" def test_logger_with_all_attributes( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.warning("log #{}", 1) sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert "span_id" in logs[0] assert logs[0]["span_id"] is None @@ -473,7 +473,7 @@ def test_logger_with_all_attributes( def test_logger_capture_parameters_from_args( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): # This is currently not supported as regular args don't get added to extra # (which we use for populating parameters). Adding this test to make that @@ -482,106 +482,106 @@ def test_logger_capture_parameters_from_args( request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.warning("Task ID: {}", 123) sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert "sentry.message.parameter.0" not in attributes def test_logger_capture_parameters_from_kwargs( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.warning("Task ID: {task_id}", task_id=123) sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert attributes["sentry.message.parameter.task_id"] == 123 def test_logger_capture_parameters_from_contextualize( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") with logger.contextualize(task_id=123): logger.warning("Log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert attributes["sentry.message.parameter.task_id"] == 123 def test_logger_capture_parameters_from_bind( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.bind(task_id=123).warning("Log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert attributes["sentry.message.parameter.task_id"] == 123 def test_logger_capture_parameters_from_patch( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.patch(lambda record: record["extra"].update(task_id=123)).warning("Log") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert attributes["sentry.message.parameter.task_id"] == 123 def test_no_parameters_no_template( - sentry_init, capture_envelopes, uninstall_integration, request + sentry_init, capture_items, uninstall_integration, request ): uninstall_integration("loguru") request.addfinalizer(logger.remove) sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") logger.warning("Logging a hardcoded warning") sentry_sdk.get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] attributes = logs[0]["attributes"] assert "sentry.message.template" not in attributes diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cac0a520e7..61df170a33 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,12 +1,10 @@ import sentry_sdk -from tests.test_metrics import envelopes_to_metrics - -def test_top_level_api(sentry_init, capture_envelopes): +def test_top_level_api(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.set_attribute("set", "value") sentry_sdk.set_attribute("removed", "value") @@ -17,14 +15,14 @@ def test_top_level_api(sentry_init, capture_envelopes): sentry_sdk.metrics.count("test", 1) sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert metric["attributes"]["set"] == "value" assert "removed" not in metric["attributes"] -def test_scope_precedence(sentry_init, capture_envelopes): +def test_scope_precedence(sentry_init, capture_items): # Order of precedence, from most important to least: # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute) # 2. current scope attributes @@ -32,7 +30,7 @@ def test_scope_precedence(sentry_init, capture_envelopes): # 4. global scope attributes sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") global_scope = sentry_sdk.get_global_scope() global_scope.set_attribute("global.attribute", "global") @@ -49,7 +47,7 @@ def test_scope_precedence(sentry_init, capture_envelopes): sentry_sdk.metrics.count("test", 1) sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert metric["attributes"]["global.attribute"] == "global" @@ -59,7 +57,7 @@ def test_scope_precedence(sentry_init, capture_envelopes): assert metric["attributes"]["overwritten.attribute"] == "current" -def test_telemetry_precedence(sentry_init, capture_envelopes): +def test_telemetry_precedence(sentry_init, capture_items): # Order of precedence, from most important to least: # 1. telemetry attributes (directly supplying attributes on creation or using set_attribute) # 2. current scope attributes @@ -67,7 +65,7 @@ def test_telemetry_precedence(sentry_init, capture_envelopes): # 4. global scope attributes sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") global_scope = sentry_sdk.get_global_scope() global_scope.set_attribute("global.attribute", "global") @@ -92,7 +90,7 @@ def test_telemetry_precedence(sentry_init, capture_envelopes): sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert metric["attributes"]["global.attribute"] == "global" @@ -103,10 +101,10 @@ def test_telemetry_precedence(sentry_init, capture_envelopes): assert metric["attributes"]["overwritten.attribute"] == "telemetry" -def test_attribute_out_of_scope(sentry_init, capture_envelopes): +def test_attribute_out_of_scope(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") with sentry_sdk.new_scope() as scope: scope.set_attribute("outofscope.attribute", "out of scope") @@ -115,16 +113,16 @@ def test_attribute_out_of_scope(sentry_init, capture_envelopes): sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert "outofscope.attribute" not in metric["attributes"] -def test_remove_attribute(sentry_init, capture_envelopes): +def test_remove_attribute(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") with sentry_sdk.new_scope() as scope: scope.set_attribute("some.attribute", 123) @@ -134,13 +132,13 @@ def test_remove_attribute(sentry_init, capture_envelopes): sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert "some.attribute" not in metric["attributes"] -def test_scope_attributes_preserialized(sentry_init, capture_envelopes): +def test_scope_attributes_preserialized(sentry_init, capture_items): def before_send_metric(metric, _): # Scope attrs show up serialized in before_send assert isinstance(metric["attributes"]["instance"], str) @@ -150,7 +148,7 @@ def before_send_metric(metric, _): sentry_init(before_send_metric=before_send_metric) - envelopes = capture_envelopes() + items = capture_items("trace_metric") class Cat: pass @@ -170,7 +168,7 @@ class Cat: sentry_sdk.get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics # Attrs originally from the scope are serialized when sent diff --git a/tests/test_logs.py b/tests/test_logs.py index ca5d1e0b2b..ffe54729d6 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -1,17 +1,14 @@ -import json import logging import os import sys import time -from typing import List, Any, Mapping, Union + import pytest from unittest import mock import sentry_sdk import sentry_sdk.logger from sentry_sdk import get_client -from sentry_sdk.envelope import Envelope -from sentry_sdk.types import Log from sentry_sdk.consts import SPANDATA, VERSION minimum_python_37 = pytest.mark.skipif( @@ -19,47 +16,6 @@ ) -def otel_attributes_to_dict(otel_attrs: "Mapping[str, Any]") -> "Mapping[str, Any]": - def _convert_attr(attr: "Mapping[str, Union[str, float, bool]]") -> "Any": - if attr["type"] == "boolean": - return attr["value"] - if attr["type"] == "double": - return attr["value"] - if attr["type"] == "integer": - return attr["value"] - if attr["value"].startswith("{"): - try: - return json.loads(attr["value"]) - except ValueError: - pass - return str(attr["value"]) - - return {k: _convert_attr(v) for (k, v) in otel_attrs.items()} - - -def envelopes_to_logs(envelopes: List[Envelope]) -> List[Log]: - res: "List[Log]" = [] - for envelope in envelopes: - for item in envelope.items: - if item.type == "log": - for log_json in item.payload.json["items"]: - log: "Log" = { - "severity_text": log_json["attributes"]["sentry.severity_text"][ - "value" - ], - "severity_number": int( - log_json["attributes"]["sentry.severity_number"]["value"] - ), - "body": log_json["body"], - "attributes": otel_attributes_to_dict(log_json["attributes"]), - "time_unix_nano": int(float(log_json["timestamp"]) * 1e9), - "trace_id": log_json["trace_id"], - "span_id": log_json.get("span_id"), - } - res.append(log) - return res - - @minimum_python_37 def test_logs_disabled_by_default(sentry_init, capture_envelopes): sentry_init() @@ -80,9 +36,9 @@ def test_logs_disabled_by_default(sentry_init, capture_envelopes): @minimum_python_37 -def test_logs_basics(sentry_init, capture_envelopes): +def test_logs_basics(sentry_init, capture_items): sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.trace("This is a 'trace' log...") sentry_sdk.logger.debug("This is a 'debug' log...") @@ -92,44 +48,44 @@ def test_logs_basics(sentry_init, capture_envelopes): sentry_sdk.logger.fatal("This is a 'fatal' log...") get_client().flush() - logs = envelopes_to_logs(envelopes) - assert logs[0].get("severity_text") == "trace" - assert logs[0].get("severity_number") == 1 + logs = [item.payload for item in items] + assert logs[0]["attributes"]["sentry.severity_text"] == "trace" + assert logs[0]["attributes"]["sentry.severity_number"] == 1 - assert logs[1].get("severity_text") == "debug" - assert logs[1].get("severity_number") == 5 + assert logs[1]["attributes"]["sentry.severity_text"] == "debug" + assert logs[1]["attributes"]["sentry.severity_number"] == 5 - assert logs[2].get("severity_text") == "info" - assert logs[2].get("severity_number") == 9 + assert logs[2]["attributes"]["sentry.severity_text"] == "info" + assert logs[2]["attributes"]["sentry.severity_number"] == 9 - assert logs[3].get("severity_text") == "warn" - assert logs[3].get("severity_number") == 13 + assert logs[3]["attributes"]["sentry.severity_text"] == "warn" + assert logs[3]["attributes"]["sentry.severity_number"] == 13 - assert logs[4].get("severity_text") == "error" - assert logs[4].get("severity_number") == 17 + assert logs[4]["attributes"]["sentry.severity_text"] == "error" + assert logs[4]["attributes"]["sentry.severity_number"] == 17 - assert logs[5].get("severity_text") == "fatal" - assert logs[5].get("severity_number") == 21 + assert logs[5]["attributes"]["sentry.severity_text"] == "fatal" + assert logs[5]["attributes"]["sentry.severity_number"] == 21 @minimum_python_37 -def test_logs_experimental_option_still_works(sentry_init, capture_envelopes): +def test_logs_experimental_option_still_works(sentry_init, capture_items): sentry_init(_experiments={"enable_logs": True}) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.error("This is an error log...") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 - assert logs[0].get("severity_text") == "error" - assert logs[0].get("severity_number") == 17 + assert logs[0]["attributes"]["sentry.severity_text"] == "error" + assert logs[0]["attributes"]["sentry.severity_number"] == 17 @minimum_python_37 -def test_logs_before_send_log(sentry_init, capture_envelopes): +def test_logs_before_send_log(sentry_init, capture_items): before_log_called = False def _before_log(record, hint): @@ -156,7 +112,7 @@ def _before_log(record, hint): enable_logs=True, before_send_log=_before_log, ) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.trace("This is a 'trace' log...") sentry_sdk.logger.debug("This is a 'debug' log...") @@ -166,19 +122,19 @@ def _before_log(record, hint): sentry_sdk.logger.fatal("This is a 'fatal' log...") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 4 - assert logs[0]["severity_text"] == "trace" - assert logs[1]["severity_text"] == "debug" - assert logs[2]["severity_text"] == "info" - assert logs[3]["severity_text"] == "warn" + assert logs[0]["attributes"]["sentry.severity_text"] == "trace" + assert logs[1]["attributes"]["sentry.severity_text"] == "debug" + assert logs[2]["attributes"]["sentry.severity_text"] == "info" + assert logs[3]["attributes"]["sentry.severity_text"] == "warn" assert before_log_called is True @minimum_python_37 def test_logs_before_send_log_experimental_option_still_works( - sentry_init, capture_envelopes + sentry_init, capture_items ): before_log_called = False @@ -194,25 +150,25 @@ def _before_log(record, hint): "before_send_log": _before_log, }, ) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.error("This is an error log...") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 - assert logs[0]["severity_text"] == "error" + assert logs[0]["attributes"]["sentry.severity_text"] == "error" assert before_log_called is True @minimum_python_37 -def test_logs_attributes(sentry_init, capture_envelopes): +def test_logs_attributes(sentry_init, capture_items): """ Passing arbitrary attributes to log messages. """ sentry_init(enable_logs=True, server_name="test-server") - envelopes = capture_envelopes() + items = capture_items("log") attrs = { "attr_int": 1, @@ -226,7 +182,7 @@ def test_logs_attributes(sentry_init, capture_envelopes): ) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert logs[0]["body"] == "The recorded value was 'some value'" for k, v in attrs.items(): @@ -241,12 +197,12 @@ def test_logs_attributes(sentry_init, capture_envelopes): @minimum_python_37 -def test_logs_message_params(sentry_init, capture_envelopes): +def test_logs_message_params(sentry_init, capture_items): """ This is the official way of how to pass vars to log messages. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.warning("The recorded value was '{int_var}'", int_var=1) sentry_sdk.logger.warning("The recorded value was '{float_var}'", float_var=2.0) @@ -260,7 +216,7 @@ def test_logs_message_params(sentry_init, capture_envelopes): sentry_sdk.logger.warning("The recorded value was hardcoded.") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert logs[0]["body"] == "The recorded value was '1'" assert logs[0]["attributes"]["sentry.message.parameter.int_var"] == 1 @@ -308,55 +264,55 @@ def test_logs_message_params(sentry_init, capture_envelopes): @minimum_python_37 -def test_logs_tied_to_transactions(sentry_init, capture_envelopes): +def test_logs_tied_to_transactions(sentry_init, capture_items): """ Log messages are also tied to transactions. """ sentry_init(enable_logs=True, traces_sample_rate=1.0) - envelopes = capture_envelopes() + items = capture_items("log") with sentry_sdk.start_transaction(name="test-transaction") as trx: sentry_sdk.logger.warning("This is a log tied to a transaction") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert "span_id" in logs[0] assert logs[0]["span_id"] == trx.span_id @minimum_python_37 -def test_logs_no_span_id_without_active_span(sentry_init, capture_envelopes): +def test_logs_no_span_id_without_active_span(sentry_init, capture_items): """ Per the metrics spec, span_id is only attached when a span is active when the telemetry is emitted. The propagation context's synthesized span_id must not be used as a fallback. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.warning("This is a log without an active span") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert logs[0]["trace_id"] is not None assert logs[0]["span_id"] is None @minimum_python_37 -def test_logs_tied_to_spans(sentry_init, capture_envelopes): +def test_logs_tied_to_spans(sentry_init, capture_items): """ Log messages are also tied to spans. """ sentry_init(enable_logs=True, traces_sample_rate=1.0) - envelopes = capture_envelopes() + items = capture_items("log") with sentry_sdk.start_transaction(name="test-transaction"): with sentry_sdk.start_span(name="test-span") as span: sentry_sdk.logger.warning("This is a log tied to a span") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert logs[0]["span_id"] == span.span_id @@ -380,18 +336,18 @@ def test_auto_flush_logs_after_100(sentry_init, capture_envelopes): @minimum_python_37 -def test_log_user_attributes(sentry_init, capture_envelopes): +def test_log_user_attributes(sentry_init, capture_items): """User attributes are sent if enable_logs is True and send_default_pii is True.""" sentry_init(enable_logs=True, send_default_pii=True) sentry_sdk.set_user({"id": "1", "email": "test@example.com", "username": "test"}) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.warning("Hello, world!") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] (log,) = logs # Check that all expected user attributes are present. @@ -403,18 +359,18 @@ def test_log_user_attributes(sentry_init, capture_envelopes): @minimum_python_37 -def test_log_no_user_attributes_if_no_pii(sentry_init, capture_envelopes): +def test_log_no_user_attributes_if_no_pii(sentry_init, capture_items): """User attributes are not if PII sending is off.""" sentry_init(enable_logs=True, send_default_pii=False) sentry_sdk.set_user({"id": "1", "email": "test@example.com", "username": "test"}) - envelopes = capture_envelopes() + items = capture_items("log") sentry_sdk.logger.warning("Hello, world!") get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] (log,) = logs assert "user.id" not in log["attributes"] @@ -462,14 +418,14 @@ def test_auto_flush_logs_after_5s(sentry_init, capture_envelopes): ], ) def test_logs_with_literal_braces( - sentry_init, capture_envelopes, message, expected_body, params + sentry_init, capture_items, message, expected_body, params ): """ Test that log messages with literal braces (like JSON) work without crashing. This is a regression test for issue #4975. """ sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") if params: sentry_sdk.logger.info(message, **params) @@ -477,7 +433,7 @@ def test_logs_with_literal_braces( sentry_sdk.logger.info(message) get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] assert len(logs) == 1 assert logs[0]["body"] == expected_body @@ -631,10 +587,10 @@ def record_lost_event(reason, data_category=None, item=None, *, quantity=1): @minimum_python_37 -def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): +def test_log_gets_attributes_from_scopes(sentry_init, capture_items): sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") global_scope = sentry_sdk.get_global_scope() global_scope.set_attribute("global.attribute", "value") @@ -647,7 +603,7 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] (log1, log2) = logs assert log1["attributes"]["global.attribute"] == "value" @@ -658,10 +614,10 @@ def test_log_gets_attributes_from_scopes(sentry_init, capture_envelopes): @minimum_python_37 -def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes): +def test_log_attributes_override_scope_attributes(sentry_init, capture_items): sentry_init(enable_logs=True) - envelopes = capture_envelopes() + items = capture_items("log") with sentry_sdk.new_scope() as scope: scope.set_attribute("durable.attribute", "value1") @@ -672,7 +628,7 @@ def test_log_attributes_override_scope_attributes(sentry_init, capture_envelopes get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] (log,) = logs assert log["attributes"]["durable.attribute"] == "value1" @@ -736,7 +692,7 @@ def test_log_array_attributes(sentry_init, capture_envelopes): @minimum_python_37 -def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes): +def test_attributes_preserialized_in_before_send(sentry_init, capture_items): """We don't surface user-held references to objects in attributes.""" def before_send_log(log, _): @@ -749,7 +705,7 @@ def before_send_log(log, _): sentry_init(enable_logs=True, before_send_log=before_send_log) - envelopes = capture_envelopes() + items = capture_items("log") class Cat: pass @@ -769,7 +725,7 @@ class Cat: get_client().flush() - logs = envelopes_to_logs(envelopes) + logs = [item.payload for item in items] (log,) = logs assert isinstance(log["attributes"]["instance"], str) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index d14fc5c7cd..f5f30ff099 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,40 +1,14 @@ import os import sys -from typing import List from unittest import mock import pytest import sentry_sdk from sentry_sdk import get_client -from sentry_sdk.envelope import Envelope -from sentry_sdk.types import Metric from sentry_sdk.consts import SPANDATA, VERSION -def envelopes_to_metrics(envelopes: "List[Envelope]") -> "List[Metric]": - res = [] # type: List[Metric] - for envelope in envelopes: - for item in envelope.items: - if item.type == "trace_metric": - for metric_json in item.payload.json["items"]: - metric: "Metric" = { - "timestamp": metric_json["timestamp"], - "trace_id": metric_json["trace_id"], - "span_id": metric_json.get("span_id"), - "name": metric_json["name"], - "type": metric_json["type"], - "value": metric_json["value"], - "unit": metric_json.get("unit"), - "attributes": { - k: v["value"] - for (k, v) in metric_json["attributes"].items() - }, - } - res.append(metric) - return res - - def test_metrics_disabled(sentry_init, capture_envelopes): sentry_init(enable_metrics=False) @@ -47,23 +21,23 @@ def test_metrics_disabled(sentry_init, capture_envelopes): assert len(envelopes) == 0 -def test_metrics_basics(sentry_init, capture_envelopes): +def test_metrics_basics(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.metrics.count("test.counter", 1) sentry_sdk.metrics.gauge("test.gauge", 42, unit="millisecond") sentry_sdk.metrics.distribution("test.distribution", 200, unit="second") get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 3 assert metrics[0]["name"] == "test.counter" assert metrics[0]["type"] == "counter" assert metrics[0]["value"] == 1.0 - assert metrics[0]["unit"] is None + assert "unit" not in metrics[0] assert "sentry.sdk.name" in metrics[0]["attributes"] assert "sentry.sdk.version" in metrics[0]["attributes"] @@ -78,15 +52,15 @@ def test_metrics_basics(sentry_init, capture_envelopes): assert metrics[2]["unit"] == "second" -def test_metrics_experimental_option(sentry_init, capture_envelopes): +def test_metrics_experimental_option(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.metrics.count("test.counter", 5) get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["name"] == "test.counter" @@ -94,9 +68,9 @@ def test_metrics_experimental_option(sentry_init, capture_envelopes): assert metrics[0]["value"] == 5.0 -def test_metrics_with_attributes(sentry_init, capture_envelopes): +def test_metrics_with_attributes(sentry_init, capture_items): sentry_init(release="1.0.0", environment="test", server_name="test-server") - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.metrics.count( "test.counter", 1, attributes={"endpoint": "/api/test", "status": "success"} @@ -104,7 +78,7 @@ def test_metrics_with_attributes(sentry_init, capture_envelopes): get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["attributes"]["endpoint"] == "/api/test" @@ -117,9 +91,9 @@ def test_metrics_with_attributes(sentry_init, capture_envelopes): assert metrics[0]["attributes"]["sentry.sdk.version"] == VERSION -def test_metrics_with_user(sentry_init, capture_envelopes): +def test_metrics_with_user(sentry_init, capture_items): sentry_init(send_default_pii=True) - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.set_user( {"id": "user-123", "email": "test@example.com", "username": "testuser"} @@ -128,7 +102,7 @@ def test_metrics_with_user(sentry_init, capture_envelopes): get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["attributes"]["user.id"] == "user-123" @@ -136,9 +110,9 @@ def test_metrics_with_user(sentry_init, capture_envelopes): assert metrics[0]["attributes"]["user.name"] == "testuser" -def test_metrics_no_user_if_pii_off(sentry_init, capture_envelopes): +def test_metrics_no_user_if_pii_off(sentry_init, capture_items): sentry_init(send_default_pii=False) - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.set_user( {"id": "user-123", "email": "test@example.com", "username": "testuser"} @@ -147,7 +121,7 @@ def test_metrics_no_user_if_pii_off(sentry_init, capture_envelopes): get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert "user.id" not in metrics[0]["attributes"] @@ -155,16 +129,16 @@ def test_metrics_no_user_if_pii_off(sentry_init, capture_envelopes): assert "user.name" not in metrics[0]["attributes"] -def test_metrics_with_span(sentry_init, capture_envelopes): +def test_metrics_with_span(sentry_init, capture_items): sentry_init(traces_sample_rate=1.0) - envelopes = capture_envelopes() + items = capture_items("trace_metric") with sentry_sdk.start_transaction(op="test", name="test-span") as transaction: sentry_sdk.metrics.count("test.span.counter", 1) get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["trace_id"] is not None @@ -172,16 +146,16 @@ def test_metrics_with_span(sentry_init, capture_envelopes): assert metrics[0]["span_id"] == transaction.span_id -def test_metrics_tracing_without_performance(sentry_init, capture_envelopes): +def test_metrics_tracing_without_performance(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") with sentry_sdk.isolation_scope() as isolation_scope: sentry_sdk.metrics.count("test.span.counter", 1) get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 propagation_context = isolation_scope._propagation_context @@ -190,10 +164,10 @@ def test_metrics_tracing_without_performance(sentry_init, capture_envelopes): # Per the metrics spec, span_id is only attached when a span is # active when the metric is emitted. The propagation context's # synthesized span_id must not be used as a fallback. - assert metrics[0]["span_id"] is None + assert "span_id" not in metrics[0] -def test_metrics_before_send(sentry_init, capture_envelopes): +def test_metrics_before_send(sentry_init, capture_items): before_metric_called = False def _before_metric(record, hint): @@ -219,20 +193,20 @@ def _before_metric(record, hint): sentry_init( before_send_metric=_before_metric, ) - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.metrics.count("test.skip", 1) sentry_sdk.metrics.count("test.keep", 1) get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["name"] == "test.keep" assert before_metric_called -def test_metrics_experimental_before_send(sentry_init, capture_envelopes): +def test_metrics_experimental_before_send(sentry_init, capture_items): before_metric_called = False def _before_metric(record, hint): @@ -260,14 +234,14 @@ def _before_metric(record, hint): "before_send_metric": _before_metric, }, ) - envelopes = capture_envelopes() + items = capture_items("trace_metric") sentry_sdk.metrics.count("test.skip", 1) sentry_sdk.metrics.count("test.keep", 1) get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] assert len(metrics) == 1 assert metrics[0]["name"] == "test.keep" assert before_metric_called @@ -351,10 +325,10 @@ def record_lost_event(reason, data_category, quantity): assert lost_event_call == ("queue_overflow", "trace_metric", 1) -def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes): +def test_metric_gets_attributes_from_scopes(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") global_scope = sentry_sdk.get_global_scope() global_scope.set_attribute("global.attribute", "value") @@ -367,7 +341,7 @@ def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes): get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric1, metric2) = metrics assert metric1["attributes"]["global.attribute"] == "value" @@ -377,10 +351,10 @@ def test_metric_gets_attributes_from_scopes(sentry_init, capture_envelopes): assert "current.attribute" not in metric2["attributes"] -def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelopes): +def test_metric_attributes_override_scope_attributes(sentry_init, capture_items): sentry_init() - envelopes = capture_envelopes() + items = capture_items("trace_metric") with sentry_sdk.new_scope() as scope: scope.set_attribute("durable.attribute", "value1") @@ -389,7 +363,7 @@ def test_metric_attributes_override_scope_attributes(sentry_init, capture_envelo get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert metric["attributes"]["durable.attribute"] == "value1" @@ -452,7 +426,7 @@ def test_log_array_attributes(sentry_init, capture_envelopes): } -def test_attributes_preserialized_in_before_send(sentry_init, capture_envelopes): +def test_attributes_preserialized_in_before_send(sentry_init, capture_items): """We don't surface user-held references to objects in attributes.""" def before_send_metric(metric, _): @@ -465,7 +439,7 @@ def before_send_metric(metric, _): sentry_init(before_send_metric=before_send_metric) - envelopes = capture_envelopes() + items = capture_items("trace_metric") class Cat: pass @@ -486,7 +460,7 @@ class Cat: get_client().flush() - metrics = envelopes_to_metrics(envelopes) + metrics = [item.payload for item in items] (metric,) = metrics assert isinstance(metric["attributes"]["instance"], str) diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 43df3c7e26..4c9d0e44ee 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -4,7 +4,6 @@ import sys import time from unittest import mock -from typing import Any import pytest @@ -18,36 +17,13 @@ ) -def envelopes_to_spans(envelopes): - res: "list[dict[str, Any]]" = [] - for envelope in envelopes: - for item in envelope.items: - if item.type == "span": - for span_json in item.payload.json["items"]: - span = { - "start_timestamp": span_json["start_timestamp"], - "end_timestamp": span_json.get("end_timestamp"), - "trace_id": span_json["trace_id"], - "span_id": span_json["span_id"], - "name": span_json["name"], - "status": span_json["status"], - "is_segment": span_json["is_segment"], - "parent_span_id": span_json.get("parent_span_id"), - "attributes": { - k: v["value"] for (k, v) in span_json["attributes"].items() - }, - } - res.append(span) - return res - - -def test_start_span(sentry_init, capture_envelopes): +def test_start_span(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment") as segment: assert segment._is_segment() is True @@ -56,7 +32,7 @@ def test_start_span(sentry_init, capture_envelopes): assert child._segment == segment sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 child, segment = spans @@ -67,7 +43,7 @@ def test_start_span(sentry_init, capture_envelopes): assert child["attributes"]["sentry.segment.name"] == "segment" assert segment["is_segment"] is True - assert segment["parent_span_id"] is None + assert "parent_span_id" not in segment assert child["is_segment"] is False assert child["parent_span_id"] == segment["span_id"] assert child["trace_id"] == segment["trace_id"] @@ -81,13 +57,13 @@ def test_start_span(sentry_init, capture_envelopes): assert segment["status"] == "ok" -def test_start_span_no_context_manager(sentry_init, capture_envelopes): +def test_start_span_no_context_manager(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") segment = sentry_sdk.traces.start_span(name="segment") child = sentry_sdk.traces.start_span(name="child") @@ -96,7 +72,7 @@ def test_start_span_no_context_manager(sentry_init, capture_envelopes): segment.end() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 child, segment = spans @@ -120,7 +96,7 @@ def test_start_span_no_context_manager(sentry_init, capture_envelopes): assert segment["status"] == "ok" -def test_span_sampled_when_created(sentry_init, capture_envelopes): +def test_span_sampled_when_created(sentry_init, capture_items): # Test that if a span is created without the context manager, it is sampled # at start_span() time @@ -133,14 +109,14 @@ def traces_sampler(sampling_context): _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") segment = sentry_sdk.traces.start_span(name="segment") segment.set_attribute("delayed_attribute", 12) segment.end() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (segment,) = spans @@ -149,13 +125,13 @@ def traces_sampler(sampling_context): assert segment["attributes"]["delayed_attribute"] == 12 -def test_start_span_attributes(sentry_init, capture_envelopes): +def test_start_span_attributes(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span( name="segment", attributes={"my_attribute": "my_value"} @@ -163,7 +139,7 @@ def test_start_span_attributes(sentry_init, capture_envelopes): ... sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -172,7 +148,7 @@ def test_start_span_attributes(sentry_init, capture_envelopes): assert span["attributes"]["my_attribute"] == "my_value" -def test_start_span_attributes_in_traces_sampler(sentry_init, capture_envelopes): +def test_start_span_attributes_in_traces_sampler(sentry_init, capture_items): def traces_sampler(sampling_context): assert "attributes" in sampling_context["span_context"] assert "my_attribute" in sampling_context["span_context"]["attributes"] @@ -186,7 +162,7 @@ def traces_sampler(sampling_context): _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span( name="segment", attributes={"my_attribute": "my_value"} @@ -194,7 +170,7 @@ def traces_sampler(sampling_context): ... sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -203,7 +179,7 @@ def traces_sampler(sampling_context): assert span["attributes"]["my_attribute"] == "my_value" -def test_sampling_context(sentry_init, capture_envelopes): +def test_sampling_context(sentry_init, capture_items): received_trace_id = None def traces_sampler(sampling_context): @@ -227,7 +203,7 @@ def traces_sampler(sampling_context): _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="span") as span: trace_id = span._trace_id @@ -235,7 +211,7 @@ def traces_sampler(sampling_context): assert received_trace_id == trace_id sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 @@ -296,13 +272,13 @@ def traces_sampler(sampling_context): ... -def test_span_attributes(sentry_init, capture_envelopes): +def test_span_attributes(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span( name="segment", attributes={"attribute1": "value"} @@ -318,7 +294,7 @@ def test_span_attributes(sentry_init, capture_envelopes): assert attributes["attribute4"] is False sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -330,13 +306,13 @@ def test_span_attributes(sentry_init, capture_envelopes): assert span["attributes"]["attribute4"] is False -def test_span_attributes_serialize_early(sentry_init, capture_envelopes): +def test_span_attributes_serialize_early(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") class Class: pass @@ -357,7 +333,7 @@ class Class: assert "Class" in attributes["attribute2"] sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -366,7 +342,7 @@ class Class: assert "Class" in span["attributes"]["attribute2"] -def test_traces_sampler_drops_span(sentry_init, capture_envelopes): +def test_traces_sampler_drops_span(sentry_init, capture_items): def traces_sampler(sampling_context): assert "attributes" in sampling_context["span_context"] assert "drop" in sampling_context["span_context"]["attributes"] @@ -381,7 +357,7 @@ def traces_sampler(sampling_context): _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="dropped", attributes={"drop": True}): ... @@ -389,7 +365,7 @@ def traces_sampler(sampling_context): ... sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -424,13 +400,13 @@ def traces_sampler(sampling_context): assert span_name_in_traces_sampler == segment.name -def test_start_inactive_span(sentry_init, capture_envelopes): +def test_start_inactive_span(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment") as segment: with sentry_sdk.traces.start_span(name="child1", active=False): @@ -439,7 +415,7 @@ def test_start_inactive_span(sentry_init, capture_envelopes): pass sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 3 child2, child1, segment = spans @@ -461,13 +437,13 @@ def test_start_inactive_span(sentry_init, capture_envelopes): assert child2["trace_id"] == segment["trace_id"] -def test_start_span_override_parent(sentry_init, capture_envelopes): +def test_start_span_override_parent(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment") as segment: with sentry_sdk.traces.start_span(name="child1"): @@ -475,7 +451,7 @@ def test_start_span_override_parent(sentry_init, capture_envelopes): pass sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 3 child2, child1, segment = spans @@ -500,13 +476,13 @@ def test_start_span_override_parent(sentry_init, capture_envelopes): assert child2["trace_id"] == segment["trace_id"] -def test_sibling_segments(sentry_init, capture_envelopes): +def test_sibling_segments(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment1"): ... @@ -515,7 +491,7 @@ def test_sibling_segments(sentry_init, capture_envelopes): ... sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 segment1, segment2 = spans @@ -523,23 +499,23 @@ def test_sibling_segments(sentry_init, capture_envelopes): assert segment1["name"] == "segment1" assert segment1["attributes"]["sentry.segment.name"] == "segment1" assert segment1["is_segment"] is True - assert segment1["parent_span_id"] is None + assert "parent_span_id" not in segment1 assert segment2["name"] == "segment2" assert segment2["attributes"]["sentry.segment.name"] == "segment2" assert segment2["is_segment"] is True - assert segment2["parent_span_id"] is None + assert "parent_span_id" not in segment2 assert segment1["trace_id"] == segment2["trace_id"] -def test_sibling_segments_new_trace(sentry_init, capture_envelopes): +def test_sibling_segments_new_trace(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment1"): ... @@ -550,7 +526,7 @@ def test_sibling_segments_new_trace(sentry_init, capture_envelopes): ... sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 segment1, segment2 = spans @@ -558,24 +534,24 @@ def test_sibling_segments_new_trace(sentry_init, capture_envelopes): assert segment1["name"] == "segment1" assert segment1["attributes"]["sentry.segment.name"] == "segment1" assert segment1["is_segment"] is True - assert segment1["parent_span_id"] is None + assert "parent_span_id" not in segment1 assert segment2["name"] == "segment2" assert segment2["attributes"]["sentry.segment.name"] == "segment2" assert segment2["is_segment"] is True - assert segment2["parent_span_id"] is None + assert "parent_span_id" not in segment2 assert segment1["trace_id"] != segment2["trace_id"] -def test_continue_trace_sampled(sentry_init, capture_envelopes): +def test_continue_trace_sampled(sentry_init, capture_items): sentry_init( # parent sampling decision takes precedence over traces_sample_rate traces_sample_rate=0.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") trace_id = "0af7651916cd43dd8448eb211c80319c" parent_span_id = "b7ad6b7169203331" @@ -598,7 +574,7 @@ def test_continue_trace_sampled(sentry_init, capture_envelopes): assert span._sample_rand == float(sample_rand) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (segment,) = spans @@ -608,14 +584,14 @@ def test_continue_trace_sampled(sentry_init, capture_envelopes): assert segment["trace_id"] == trace_id -def test_continue_trace_unsampled(sentry_init, capture_envelopes): +def test_continue_trace_unsampled(sentry_init, capture_items): sentry_init( # parent sampling decision takes precedence over traces_sample_rate traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") trace_id = "0af7651916cd43dd8448eb211c80319c" parent_span_id = "b7ad6b7169203331" @@ -638,20 +614,20 @@ def test_continue_trace_unsampled(sentry_init, capture_envelopes): assert span.span_id == "0000000000000000" sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 0 def test_unsampled_spans_produce_client_report( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): sentry_init( traces_sample_rate=0.0, _experiments={"trace_lifecycle": "stream"}, ) - envelopes = capture_envelopes() + items = capture_items("span") record_lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="segment"): @@ -662,7 +638,7 @@ def test_unsampled_spans_produce_client_report( sentry_sdk.get_client().flush() - spans = envelopes_to_spans(envelopes) + spans = [item.payload for item in items] assert not spans assert record_lost_event_calls == [ @@ -673,14 +649,14 @@ def test_unsampled_spans_produce_client_report( def test_no_client_reports_if_tracing_is_off( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): sentry_init( traces_sample_rate=None, _experiments={"trace_lifecycle": "stream"}, ) - envelopes = capture_envelopes() + items = capture_items("span") record_lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="segment"): @@ -691,19 +667,19 @@ def test_no_client_reports_if_tracing_is_off( sentry_sdk.get_client().flush() - spans = envelopes_to_spans(envelopes) + spans = [item.payload for item in items] assert not spans assert not record_lost_event_calls -def test_continue_trace_no_sample_rand(sentry_init, capture_envelopes): +def test_continue_trace_no_sample_rand(sentry_init, capture_items): sentry_init( # parent sampling decision takes precedence over traces_sample_rate traces_sample_rate=0.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") trace_id = "0af7651916cd43dd8448eb211c80319c" parent_span_id = "b7ad6b7169203331" @@ -725,7 +701,7 @@ def test_continue_trace_no_sample_rand(sentry_init, capture_envelopes): assert isinstance(span._sample_rand, float) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (segment,) = spans @@ -796,13 +772,13 @@ def test_outgoing_traceparent_and_baggage_when_noop_span_is_active( assert baggage_items["sentry-trace_id"] == propagation_trace_id -def test_trace_decorator(sentry_init, capture_envelopes): +def test_trace_decorator(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace def traced_function(): ... @@ -810,7 +786,7 @@ def traced_function(): ... traced_function() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -822,13 +798,13 @@ def traced_function(): ... assert span["status"] == "ok" -def test_trace_decorator_arguments(sentry_init, capture_envelopes): +def test_trace_decorator_arguments(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace(name="traced", attributes={"traced.attribute": 123}) def traced_function(): ... @@ -836,7 +812,7 @@ def traced_function(): ... traced_function() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -846,13 +822,13 @@ def traced_function(): ... assert span["status"] == "ok" -def test_trace_decorator_inactive(sentry_init, capture_envelopes): +def test_trace_decorator_inactive(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace(name="outer", active=False) def traced_function(): @@ -862,25 +838,25 @@ def traced_function(): traced_function() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 (span1, span2) = spans assert span1["name"] == "inner" - assert span1["parent_span_id"] != span2["span_id"] + assert span1.get("parent_span_id") != span2["span_id"] assert span2["name"] == "outer" @minimum_python_38 -def test_trace_decorator_async(sentry_init, capture_envelopes): +def test_trace_decorator_async(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace async def traced_function(): ... @@ -888,7 +864,7 @@ async def traced_function(): ... asyncio.run(traced_function()) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -901,13 +877,13 @@ async def traced_function(): ... @minimum_python_38 -def test_trace_decorator_async_arguments(sentry_init, capture_envelopes): +def test_trace_decorator_async_arguments(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace(name="traced", attributes={"traced.attribute": 123}) async def traced_function(): ... @@ -915,7 +891,7 @@ async def traced_function(): ... asyncio.run(traced_function()) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -926,13 +902,13 @@ async def traced_function(): ... @minimum_python_38 -def test_trace_decorator_async_inactive(sentry_init, capture_envelopes): +def test_trace_decorator_async_inactive(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") @sentry_sdk.traces.trace(name="outer", active=False) async def traced_function(): @@ -942,24 +918,24 @@ async def traced_function(): asyncio.run(traced_function()) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 (span1, span2) = spans assert span1["name"] == "inner" - assert span1["parent_span_id"] != span2["span_id"] + assert span1.get("parent_span_id") != span2["span_id"] assert span2["name"] == "outer" -def test_set_span_status(sentry_init, capture_envelopes): +def test_set_span_status(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="span") as span: span.status = SpanStatus.ERROR @@ -968,7 +944,7 @@ def test_set_span_status(sentry_init, capture_envelopes): span.status = "error" sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 (span1, span2) = spans @@ -977,20 +953,20 @@ def test_set_span_status(sentry_init, capture_envelopes): assert span2["status"] == "error" -def test_set_span_status_on_error(sentry_init, capture_envelopes): +def test_set_span_status_on_error(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream"}, ) - events = capture_envelopes() + items = capture_items("span") with pytest.raises(ValueError): with sentry_sdk.traces.start_span(name="span") as span: raise ValueError("oh no!") sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans @@ -998,19 +974,19 @@ def test_set_span_status_on_error(sentry_init, capture_envelopes): assert span["status"] == "error" -def test_set_span_status_on_ignored_span(sentry_init, capture_envelopes): +def test_set_span_status_on_ignored_span(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream", "ignore_spans": ["ignored"]}, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="ignored") as span: span.status = "error" sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 0 @@ -1099,7 +1075,7 @@ def test_set_span_status_on_ignored_span(sentry_init, capture_envelopes): ], ) def test_ignore_spans( - sentry_init, capture_envelopes, ignore_spans, name, attributes, ignored + sentry_init, capture_items, ignore_spans, name, attributes, ignored ): sentry_init( traces_sample_rate=1.0, @@ -1109,7 +1085,7 @@ def test_ignore_spans( }, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name=name, attributes=attributes) as span: if ignored: @@ -1120,7 +1096,7 @@ def test_ignore_spans( assert isinstance(span, StreamedSpan) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] if ignored: assert len(spans) == 0 @@ -1131,7 +1107,7 @@ def test_ignore_spans( def test_ignore_spans_basic( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): sentry_init( traces_sample_rate=1.0, @@ -1141,7 +1117,7 @@ def test_ignore_spans_basic( }, ) - events = capture_envelopes() + items = capture_items("span") lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="ignored") as ignored_span: @@ -1152,19 +1128,19 @@ def test_ignore_spans_basic( sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 1 (span,) = spans assert span["name"] == "not ignored" - assert span["parent_span_id"] is None + assert "parent_span_id" not in span assert len(lost_event_calls) == 1 assert lost_event_calls[0] == ("ignored", "span", None, 1) def test_ignore_spans_ignored_segment_drops_whole_tree( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): # Ignored segments should drop the whole span tree. sentry_init( @@ -1175,7 +1151,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree( }, ) - events = capture_envelopes() + items = capture_items("span") lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="ignored") as ignored_span: @@ -1191,7 +1167,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree( assert isinstance(span2, NoOpStreamedSpan) sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 0 @@ -1201,7 +1177,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree( def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): # Ignored segments should drop the whole span tree. sentry_init( @@ -1212,7 +1188,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( }, ) - events = capture_envelopes() + items = capture_items("span") lost_event_calls = capture_record_lost_event_calls() ignored_span = sentry_sdk.traces.start_span(name="ignored") @@ -1233,7 +1209,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 0 @@ -1243,7 +1219,7 @@ def test_ignore_spans_ignored_segment_drops_whole_tree_explicit_parent_span( def test_ignore_spans_set_ignored_child_span_as_parent( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): # Ignored non-segment spans should NOT drop the whole subtree under them. sentry_init( @@ -1254,7 +1230,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent( }, ) - events = capture_envelopes() + items = capture_items("span") lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="segment") as segment: @@ -1271,7 +1247,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent( assert span._parent_span_id == segment.span_id sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 (child, segment) = spans @@ -1285,7 +1261,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent( def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): # Ignored non-segment spans should NOT drop the whole subtree under them. sentry_init( @@ -1296,7 +1272,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( }, ) - events = capture_envelopes() + items = capture_items("span") lost_event_calls = capture_record_lost_event_calls() segment = sentry_sdk.traces.start_span(name="segment") @@ -1325,7 +1301,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( segment.end() sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 2 (child, segment) = spans @@ -1338,7 +1314,7 @@ def test_ignore_spans_set_ignored_child_span_as_parent_explicit_parent_span( assert lost_event_call == ("ignored", "span", None, 1) -def test_ignore_spans_reparenting(sentry_init, capture_envelopes): +def test_ignore_spans_reparenting(sentry_init, capture_items): sentry_init( traces_sample_rate=1.0, _experiments={ @@ -1347,7 +1323,7 @@ def test_ignore_spans_reparenting(sentry_init, capture_envelopes): }, ) - events = capture_envelopes() + items = capture_items("span") with sentry_sdk.traces.start_span(name="segment") as span1: assert span1.sampled is True @@ -1368,7 +1344,7 @@ def test_ignore_spans_reparenting(sentry_init, capture_envelopes): assert span5._parent_span_id == span3.span_id sentry_sdk.get_client().flush() - spans = envelopes_to_spans(events) + spans = [item.payload for item in items] assert len(spans) == 3 (span5, span3, span1) = spans @@ -1380,14 +1356,14 @@ def test_ignore_spans_reparenting(sentry_init, capture_envelopes): def test_ignored_spans_produce_client_report( - sentry_init, capture_envelopes, capture_record_lost_event_calls + sentry_init, capture_items, capture_record_lost_event_calls ): sentry_init( traces_sample_rate=1.0, _experiments={"trace_lifecycle": "stream", "ignore_spans": ["ignored"]}, ) - envelopes = capture_envelopes() + items = capture_items("span") record_lost_event_calls = capture_record_lost_event_calls() with sentry_sdk.traces.start_span(name="ignored"): @@ -1398,7 +1374,7 @@ def test_ignored_spans_produce_client_report( sentry_sdk.get_client().flush() - spans = envelopes_to_spans(envelopes) + spans = [item.payload for item in items] assert not spans # All three spans will be ignored since the segment is ignored @@ -1411,7 +1387,7 @@ def test_ignored_spans_produce_client_report( @mock.patch("sentry_sdk.profiler.continuous_profiler.DEFAULT_SAMPLING_FREQUENCY", 21) def test_segment_span_has_profiler_id( - sentry_init, capture_envelopes, teardown_profiling + sentry_init, capture_items, capture_envelopes, teardown_profiling ): sentry_init( traces_sample_rate=1.0, @@ -1423,6 +1399,7 @@ def test_segment_span_has_profiler_id( "continuous_profiling_auto_start": True, }, ) + items = capture_items("span") envelopes = capture_envelopes() with sentry_sdk.traces.start_span(name="profiled segment"): @@ -1431,7 +1408,7 @@ def test_segment_span_has_profiler_id( sentry_sdk.get_client().flush() time.sleep(0.3) # wait for profiler to flush - spans = envelopes_to_spans(envelopes) + spans = [item.payload for item in items] assert len(spans) == 1 assert "sentry.profiler_id" in spans[0]["attributes"] @@ -1445,7 +1422,7 @@ def test_segment_span_has_profiler_id( def test_segment_span_no_profiler_id_when_unsampled( - sentry_init, capture_envelopes, teardown_profiling + sentry_init, capture_items, capture_envelopes, teardown_profiling ): sentry_init( traces_sample_rate=1.0, @@ -1457,6 +1434,7 @@ def test_segment_span_no_profiler_id_when_unsampled( "continuous_profiling_auto_start": True, }, ) + items = capture_items("span") envelopes = capture_envelopes() with sentry_sdk.traces.start_span(name="segment"): @@ -1465,7 +1443,7 @@ def test_segment_span_no_profiler_id_when_unsampled( sentry_sdk.get_client().flush() time.sleep(0.2) - spans = envelopes_to_spans(envelopes) + spans = [item.payload for item in items] assert len(spans) == 1 assert "sentry.profiler_id" not in spans[0]["attributes"] From ceda157af4684915bdca7ac8f104495aa9e2824c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 13:21:12 +0200 Subject: [PATCH 06/11] test: Move batcher fork safety test to batcher tests (#6225) Just moving one test case to another file. --- tests/tracing/test_span_batcher.py | 68 ++++++++++++++++++++++++++++ tests/tracing/test_span_streaming.py | 65 -------------------------- 2 files changed, 68 insertions(+), 65 deletions(-) diff --git a/tests/tracing/test_span_batcher.py b/tests/tracing/test_span_batcher.py index 15823a421c..4286691785 100644 --- a/tests/tracing/test_span_batcher.py +++ b/tests/tracing/test_span_batcher.py @@ -1,6 +1,10 @@ +import os +import sys import time from unittest import mock +import pytest + import sentry_sdk from sentry_sdk._span_batcher import SpanBatcher @@ -425,3 +429,67 @@ def test_transport_format(sentry_init, capture_envelopes): assert "value" in value assert "type" in value assert value["type"] in ("string", "boolean", "integer", "double", "array") + + +@pytest.mark.skipif( + sys.platform == "win32" + or not hasattr(os, "fork") + or not hasattr(os, "register_at_fork"), + reason="requires POSIX fork and os.register_at_fork (Python 3.7+)", +) +def test_span_batcher_lock_reset_in_child_after_fork(sentry_init): + """Regression test for the SpanBatcher fork-deadlock fix. + + If os.fork() runs while another thread holds SpanBatcher._lock, the + child inherits the lock locked. The holding thread does not exist in + the child, so the lock can never be released and _ensure_thread + deadlocks forever. The after-fork hook must replace the lock with a + fresh one in the child and reset + _flusher / _flusher_pid / _span_buffer / _running_size / _active / + _flush_event. + """ + sentry_init( + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + batcher = sentry_sdk.get_client().span_batcher + assert batcher is not None + + original_lock = batcher._lock + original_lock.acquire() + + batcher._span_buffer["test-trace-id"].append(object()) + batcher._running_size["test-trace-id"] = 42 + batcher._active.flag = True + batcher._flush_event.set() + batcher._running = False + + pid = os.fork() + if pid == 0: + replaced = batcher._lock is not original_lock + unheld = batcher._lock.acquire(blocking=False) + + flusher_reset = batcher._flusher is None and batcher._flusher_pid is None + span_buffer_reset = len(batcher._span_buffer) == 0 + running_size_reset = len(batcher._running_size) == 0 + + active_reset = not getattr(batcher._active, "flag", False) + event_reset = not batcher._flush_event.is_set() + running_reset = batcher._running is True + + os._exit( + 0 + if replaced + and unheld + and flusher_reset + and span_buffer_reset + and running_size_reset + and active_reset + and event_reset + and running_reset + else 1 + ) + + original_lock.release() + _, status = os.waitpid(pid, 0) + assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 4c9d0e44ee..48ebbedbc7 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1,5 +1,4 @@ import asyncio -import os import re import sys import time @@ -1520,67 +1519,3 @@ def test_default_attributes(sentry_init, capture_envelopes): "sentry.release": {"value": "1.0.0", "type": "string"}, "sentry.origin": {"value": "manual", "type": "string"}, } - - -@pytest.mark.skipif( - sys.platform == "win32" - or not hasattr(os, "fork") - or not hasattr(os, "register_at_fork"), - reason="requires POSIX fork and os.register_at_fork (Python 3.7+)", -) -def test_span_batcher_lock_reset_in_child_after_fork(sentry_init): - """Regression test for the SpanBatcher fork-deadlock fix. - - If os.fork() runs while another thread holds SpanBatcher._lock, the - child inherits the lock locked. The holding thread does not exist in - the child, so the lock can never be released and _ensure_thread - deadlocks forever. The after-fork hook must replace the lock with a - fresh one in the child and reset - _flusher / _flusher_pid / _span_buffer / _running_size / _active / - _flush_event. - """ - sentry_init( - traces_sample_rate=1.0, - _experiments={"trace_lifecycle": "stream"}, - ) - batcher = sentry_sdk.get_client().span_batcher - assert batcher is not None - - original_lock = batcher._lock - original_lock.acquire() - - batcher._span_buffer["test-trace-id"].append(object()) - batcher._running_size["test-trace-id"] = 42 - batcher._active.flag = True - batcher._flush_event.set() - batcher._running = False - - pid = os.fork() - if pid == 0: - replaced = batcher._lock is not original_lock - unheld = batcher._lock.acquire(blocking=False) - - flusher_reset = batcher._flusher is None and batcher._flusher_pid is None - span_buffer_reset = len(batcher._span_buffer) == 0 - running_size_reset = len(batcher._running_size) == 0 - - active_reset = not getattr(batcher._active, "flag", False) - event_reset = not batcher._flush_event.is_set() - running_reset = batcher._running is True - - os._exit( - 0 - if replaced - and unheld - and flusher_reset - and span_buffer_reset - and running_size_reset - and active_reset - and event_reset - and running_reset - else 1 - ) - - original_lock.release() - _, status = os.waitpid(pid, 0) - assert os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0 From c94e750e67a596f05a6e0701cb7e020120b012bc Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 13:21:52 +0200 Subject: [PATCH 07/11] feat(argv): Support span streaming (#6227) ### Description Reintroducing the argv integration port from https://github.com/getsentry/sentry-python/pull/6147. Work on full support for array attributes in the product is still ongoing, but at least the trace will now load (the attribute won't be displayed), so we're not breaking anything with this change. #### Issues - Closes https://github.com/getsentry/sentry-python/issues/5998 - Closes https://linear.app/getsentry/issue/PY-2300/migrate-argv-to-span-first #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) --- sentry_sdk/traces.py | 9 +++++++++ tests/tracing/test_span_streaming.py | 1 + 2 files changed, 10 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 39f1425802..7f3997438f 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -7,6 +7,7 @@ sentry_sdk.init(_experiments={"trace_lifecycle": "stream"}). """ +import sys import uuid import warnings from datetime import datetime, timedelta, timezone @@ -306,6 +307,8 @@ def __init__( self._start_profile() self._set_profile_id(get_profiler_id()) + self._set_segment_attributes() + self._start() def __repr__(self) -> str: @@ -564,6 +567,12 @@ def _start_profile(self) -> None: self._continuous_profile = try_profile_lifecycle_trace_start() + def _set_segment_attributes(self) -> None: + if not self._is_segment(): + return + + self.set_attribute("process.command_args", sys.argv) + class NoOpStreamedSpan(StreamedSpan): __slots__ = ( diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index 48ebbedbc7..0e095b5147 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1510,6 +1510,7 @@ def test_default_attributes(sentry_init, capture_envelopes): assert item.payload.json["items"][0]["attributes"] == { "thread.id": {"value": mock.ANY, "type": "string"}, "thread.name": {"value": "MainThread", "type": "string"}, + "process.command_args": {"value": mock.ANY, "type": "array"}, "sentry.segment.id": {"value": mock.ANY, "type": "string"}, "sentry.segment.name": {"value": "test", "type": "string"}, "sentry.sdk.name": {"value": "sentry.python", "type": "string"}, From 7d2620f011da03b1137663673eb31ed234406b59 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 13:38:04 +0200 Subject: [PATCH 08/11] fix: Make sure `http.server` spans are segments (#6230) ### Description See https://github.com/getsentry/sentry-python/issues/6217 #### Issues * Resolves https://linear.app/getsentry/issue/PY-2407/force-a-new-segment-where-we-were-creating-transactions * Resolves https://github.com/getsentry/sentry-python/issues/6217 #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) --- sentry_sdk/integrations/aiohttp.py | 1 + sentry_sdk/integrations/asgi.py | 8 ++- sentry_sdk/integrations/wsgi.py | 1 + tests/integrations/aiohttp/test_aiohttp.py | 71 ++++++++++------------ 4 files changed, 40 insertions(+), 41 deletions(-) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 198f7ae74d..26f96a672f 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -184,6 +184,7 @@ async def sentry_app_handle( **client_address_attributes, **header_attributes, }, + parent_span=None, ) else: transaction = continue_trace( diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index e62a9a5807..151b5c58ee 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -263,7 +263,9 @@ async def _run_app( attributes["sentry.op"] = f"{ty}.server" segment = sentry_sdk.traces.start_span( - name=transaction_name, attributes=attributes + name=transaction_name, + attributes=attributes, + parent_span=None, ) else: sentry_sdk.traces.new_trace() @@ -274,7 +276,9 @@ async def _run_app( attributes["sentry.op"] = OP.HTTP_SERVER segment = sentry_sdk.traces.start_span( - name=transaction_name, attributes=attributes + name=transaction_name, + attributes=attributes, + parent_span=None, ) span_ctx = segment or nullcontext() diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 8814a82858..91d26ba8ac 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -141,6 +141,7 @@ def __call__( "sentry.origin": self.span_origin, "sentry.op": OP.HTTP_SERVER, }, + parent_span=None, ) else: transaction = continue_trace( diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 4a5a1f1ec3..de2d3a9998 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1160,29 +1160,26 @@ async def hello(request): sentry_sdk.flush() - # Spans finish inner-first, so the segment is the last item. # The aiohttp_client fixture is itself sentry-instrumented and emits the - # outer http.client segment; the server-side http.server span is its only - # child. Asserting the exact length confirms no other spans leak in. + # first http.client segment; the server-side http.server span is the other + # segment. Asserting the exact length confirms no other spans leak in. assert len(items) == 2 - segment = items.pop().payload - (server_span,) = [item.payload for item in items] + server_span, client_span = [item.payload for item in items] - assert segment["is_segment"] is True - assert segment["attributes"]["sentry.op"] == "http.client" - assert segment["name"] == ( - "tests.integrations.aiohttp.test_aiohttp." - "test_tracing_span_streaming..hello" - ) - assert segment["attributes"]["sentry.span.source"] == "component" + assert client_span["is_segment"] is True + assert client_span["attributes"]["sentry.op"] == "http.client" + assert client_span["name"].startswith("GET http://127.0.0.1:") - assert server_span["is_segment"] is False - assert server_span["name"] == "generic AIOHTTP request" + assert server_span["is_segment"] is True + assert ( + server_span["name"] + == "tests.integrations.aiohttp.test_aiohttp.test_tracing_span_streaming..hello" + ) assert server_span["attributes"]["sentry.op"] == "http.server" assert server_span["attributes"]["sentry.origin"] == "auto.http.aiohttp" assert server_span["attributes"]["http.response.status_code"] == 200 - assert server_span["attributes"]["sentry.span.source"] == "route" + assert server_span["attributes"]["sentry.span.source"] == "component" assert server_span["status"] == "ok" # No query string on the request, so the attribute should be omitted. assert "url.query" not in server_span["attributes"] @@ -1315,13 +1312,9 @@ async def hello(request): sentry_sdk.flush() - # The integration sets http.request.body.data on the segment span. In this - # test the segment is the aiohttp_client's outgoing http.client span (the - # test client is itself sentry-instrumented). In production with separate - # processes, the server span is the segment; the assertion is the same. - segment = items.pop().payload - assert segment["is_segment"] is True - assert segment["attributes"]["http.request.body.data"] == json.dumps(body) + server_segment, client_segment = [item.payload for item in items] + assert server_segment["is_segment"] is True + assert server_segment["attributes"]["http.request.body.data"] == json.dumps(body) @pytest.mark.asyncio @@ -1349,9 +1342,11 @@ async def hello(request): sentry_sdk.flush() - segment = items.pop().payload - assert segment["is_segment"] is True - assert segment["attributes"]["http.request.body.data"] == BODY_NOT_READ_MESSAGE + server_segment, client_segment = [item.payload for item in items] + assert server_segment["is_segment"] is True + assert ( + server_segment["attributes"]["http.request.body.data"] == BODY_NOT_READ_MESSAGE + ) @pytest.mark.asyncio @@ -1381,9 +1376,12 @@ async def hello(request): sentry_sdk.flush() - segment = items.pop().payload - assert segment["is_segment"] is True - assert segment["attributes"]["http.request.body.data"] == OVER_SIZE_LIMIT_SUBSTITUTE + server_segment, client_segment = [item.payload for item in items] + assert server_segment["is_segment"] is True + assert ( + server_segment["attributes"]["http.request.body.data"] + == OVER_SIZE_LIMIT_SUBSTITUTE + ) @pytest.mark.asyncio @@ -1411,10 +1409,9 @@ async def hello(request): sentry_sdk.flush() assert len(items) == 2 - items.pop() # drop the test client's outer segment - (server_span,) = [item.payload for item in items] + server_segment, client_segment = [item.payload for item in items] - assert server_span["attributes"]["url.query"] == "foo=bar&baz=qux" + assert server_segment["attributes"]["url.query"] == "foo=bar&baz=qux" @pytest.mark.asyncio @@ -1466,15 +1463,11 @@ async def hello(request): sentry_sdk.flush() assert len(items) == 2 - segment = items.pop().payload - (server_span,) = [item.payload for item in items] - - assert segment["name"] == expected_name - assert segment["attributes"]["sentry.span.source"] == expected_source + server_segment, client_segment = [item.payload for item in items] - assert server_span["name"] == "generic AIOHTTP request" - assert not server_span["is_segment"] - assert server_span["attributes"]["sentry.span.source"] == "route" + assert server_segment["name"] == expected_name + assert server_segment["is_segment"] + assert server_segment["attributes"]["sentry.span.source"] == expected_source @pytest.mark.asyncio From d5a2db9c56a40ff6ed21467da59bd0d0ffefe302 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 14:33:07 +0200 Subject: [PATCH 09/11] feat: Support feature flags in span first (#6234) ### Description Set [`flag.evaluation.{key}`](https://getsentry.github.io/sentry-conventions/attributes/flag/#flag-evaluation-key) as span attribute in span streaming mode. Add a span streaming test variant to affected integrations: - Unleash - Statsig - Launchdarkly - OpenFeature #### Issues - Closes https://linear.app/getsentry/issue/PY-2141/support-feature-flags-in-span-first - Closes https://github.com/getsentry/sentry-python/issues/5676 - Closes https://linear.app/getsentry/issue/PY-2372/migrate-unleash-to-span-first - Closes https://github.com/getsentry/sentry-python/issues/6070 - Closes https://linear.app/getsentry/issue/PY-2364/migrate-statsig-to-span-first - Closes https://github.com/getsentry/sentry-python/issues/6062 - Closes https://linear.app/getsentry/issue/PY-2335/migrate-launchdarkly-to-span-first - Closes https://github.com/getsentry/sentry-python/issues/6033 - Closes https://linear.app/getsentry/issue/PY-2344/migrate-openfeature-to-span-first - Closes https://github.com/getsentry/sentry-python/issues/6042 #### Reminders - Please add tests to validate your changes, and lint your code using `tox -e linters`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) --- sentry_sdk/feature_flags.py | 15 ++++-- .../launchdarkly/test_launchdarkly.py | 51 +++++++++++++++---- .../openfeature/test_openfeature.py | 46 +++++++++++++---- tests/integrations/statsig/test_statsig.py | 43 ++++++++++++---- tests/integrations/unleash/test_unleash.py | 44 ++++++++++++---- 5 files changed, 159 insertions(+), 40 deletions(-) diff --git a/sentry_sdk/feature_flags.py b/sentry_sdk/feature_flags.py index de0cefbcad..2bcded8f91 100644 --- a/sentry_sdk/feature_flags.py +++ b/sentry_sdk/feature_flags.py @@ -2,6 +2,7 @@ import sentry_sdk from sentry_sdk._lru_cache import LRUCache from sentry_sdk.tracing import Span +from sentry_sdk.tracing_utils import has_span_streaming_enabled from threading import Lock from typing import TYPE_CHECKING, Any @@ -58,9 +59,17 @@ def add_feature_flag(flag: str, result: bool) -> None: Records a flag and its value to be sent on subsequent error events. We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. """ + client = sentry_sdk.get_client() + flags = sentry_sdk.get_isolation_scope().flags flags.set(flag, result) - span = sentry_sdk.get_current_span() - if span and isinstance(span, Span): - span.set_flag(f"flag.evaluation.{flag}", result) + if has_span_streaming_enabled(client.options): + span = sentry_sdk.traces._get_current_streamed_span() + if span and isinstance(span, sentry_sdk.traces.StreamedSpan): + span.set_attribute(f"flag.evaluation.{flag}", result) + + else: + span = sentry_sdk.get_current_span() + if span and isinstance(span, Span): + span.set_flag(f"flag.evaluation.{flag}", result) diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py index e588b596d3..80ff3d408d 100644 --- a/tests/integrations/launchdarkly/test_launchdarkly.py +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -216,8 +216,17 @@ def test_launchdarkly_integration_did_not_enable(monkeypatch): "use_global_client", (False, True), ) +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) def test_launchdarkly_span_integration( - sentry_init, use_global_client, capture_events, uninstall_integration + sentry_init, + use_global_client, + capture_events, + capture_items, + uninstall_integration, + span_streaming, ): td = TestData.data_source() td.update(td.flag("hello").variation_for_all(True)) @@ -229,23 +238,47 @@ def test_launchdarkly_span_integration( uninstall_integration(LaunchDarklyIntegration.identifier) if use_global_client: ldclient.set_config(config) - sentry_init(traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration()]) + sentry_init( + traces_sample_rate=1.0, + integrations=[LaunchDarklyIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) client = ldclient.get() else: client = LDClient(config=config) sentry_init( traces_sample_rate=1.0, integrations=[LaunchDarklyIntegration(ld_client=client)], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, ) - events = capture_events() + if span_streaming: + items = capture_items("span") - with start_transaction(name="hi"): - with start_span(op="foo", name="bar"): + with sentry_sdk.traces.start_span(name="bar"): client.variation("hello", Context.create("my-org", "organization"), False) client.variation("other", Context.create("my-org", "organization"), False) - (event,) = events - assert event["spans"][0]["data"] == ApproxDict( - {"flag.evaluation.hello": True, "flag.evaluation.other": False} - ) + sentry_sdk.flush() + + assert len(items) == 1 + span = items[0].payload + assert span["attributes"]["flag.evaluation.hello"] is True + assert span["attributes"]["flag.evaluation.other"] is False + + else: + events = capture_events() + + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.variation( + "hello", Context.create("my-org", "organization"), False + ) + client.variation( + "other", Context.create("my-org", "organization"), False + ) + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.other": False} + ) diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py index 46acc61ae7..845c6c371f 100644 --- a/tests/integrations/openfeature/test_openfeature.py +++ b/tests/integrations/openfeature/test_openfeature.py @@ -155,25 +155,51 @@ async def runner(): } +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) def test_openfeature_span_integration( - sentry_init, capture_events, uninstall_integration + sentry_init, + capture_events, + capture_items, + uninstall_integration, + span_streaming, ): uninstall_integration(OpenFeatureIntegration.identifier) - sentry_init(traces_sample_rate=1.0, integrations=[OpenFeatureIntegration()]) + sentry_init( + traces_sample_rate=1.0, + integrations=[OpenFeatureIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) api.set_provider( InMemoryProvider({"hello": InMemoryFlag("on", {"on": True, "off": False})}) ) client = api.get_client() - events = capture_events() - - with start_transaction(name="hi"): - with start_span(op="foo", name="bar"): + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="bar"): client.get_boolean_value("hello", default_value=False) client.get_boolean_value("world", default_value=False) - (event,) = events - assert event["spans"][0]["data"] == ApproxDict( - {"flag.evaluation.hello": True, "flag.evaluation.world": False} - ) + sentry_sdk.flush() + + assert len(items) == 1 + span = items[0].payload + assert span["attributes"]["flag.evaluation.hello"] is True + assert span["attributes"]["flag.evaluation.world"] is False + + else: + events = capture_events() + + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.get_boolean_value("hello", default_value=False) + client.get_boolean_value("world", default_value=False) + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.world": False} + ) diff --git a/tests/integrations/statsig/test_statsig.py b/tests/integrations/statsig/test_statsig.py index 5eb2cf39f3..4d091ddc30 100644 --- a/tests/integrations/statsig/test_statsig.py +++ b/tests/integrations/statsig/test_statsig.py @@ -185,19 +185,44 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): statsig.check_gate = original_check_gate -def test_statsig_span_integration(sentry_init, capture_events, uninstall_integration): +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) +def test_statsig_span_integration( + sentry_init, capture_events, capture_items, uninstall_integration, span_streaming +): uninstall_integration(StatsigIntegration.identifier) with mock_statsig({"hello": True}): - sentry_init(traces_sample_rate=1.0, integrations=[StatsigIntegration()]) - events = capture_events() + sentry_init( + traces_sample_rate=1.0, + integrations=[StatsigIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) user = StatsigUser(user_id="user-id") - with start_transaction(name="hi"): - with start_span(op="foo", name="bar"): + + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="hi"): statsig.check_gate(user, "hello") statsig.check_gate(user, "world") - (event,) = events - assert event["spans"][0]["data"] == ApproxDict( - {"flag.evaluation.hello": True, "flag.evaluation.world": False} - ) + sentry_sdk.flush() + + assert len(items) == 1 + span = items[0].payload + assert span["attributes"]["flag.evaluation.hello"] is True + assert span["attributes"]["flag.evaluation.world"] is False + + else: + events = capture_events() + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + statsig.check_gate(user, "hello") + statsig.check_gate(user, "world") + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.world": False} + ) diff --git a/tests/integrations/unleash/test_unleash.py b/tests/integrations/unleash/test_unleash.py index 98a6188181..1753d78626 100644 --- a/tests/integrations/unleash/test_unleash.py +++ b/tests/integrations/unleash/test_unleash.py @@ -168,19 +168,45 @@ def test_wrapper_attributes(sentry_init, uninstall_integration): assert client.is_enabled.__qualname__ == original_is_enabled.__qualname__ -def test_unleash_span_integration(sentry_init, capture_events, uninstall_integration): +@pytest.mark.parametrize( + "span_streaming", + [True, False], +) +def test_unleash_span_integration( + sentry_init, capture_events, capture_items, uninstall_integration, span_streaming +): uninstall_integration(UnleashIntegration.identifier) with mock_unleash_client(): - sentry_init(traces_sample_rate=1.0, integrations=[UnleashIntegration()]) - events = capture_events() + sentry_init( + traces_sample_rate=1.0, + integrations=[UnleashIntegration()], + _experiments={"trace_lifecycle": "stream" if span_streaming else "static"}, + ) + client = UnleashClient() # type: ignore[arg-type] - with start_transaction(name="hi"): - with start_span(op="foo", name="bar"): + + if span_streaming: + items = capture_items("span") + with sentry_sdk.traces.start_span(name="bar"): client.is_enabled("hello") client.is_enabled("other") - (event,) = events - assert event["spans"][0]["data"] == ApproxDict( - {"flag.evaluation.hello": True, "flag.evaluation.other": False} - ) + sentry_sdk.flush() + + assert len(items) == 1 + span = items[0].payload + assert span["attributes"]["flag.evaluation.hello"] is True + assert span["attributes"]["flag.evaluation.other"] is False + + else: + events = capture_events() + with start_transaction(name="hi"): + with start_span(op="foo", name="bar"): + client.is_enabled("hello") + client.is_enabled("other") + + (event,) = events + assert event["spans"][0]["data"] == ApproxDict( + {"flag.evaluation.hello": True, "flag.evaluation.other": False} + ) From bc94c96248bee27b4273ef523216bf53940e464f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 15:16:34 +0200 Subject: [PATCH 10/11] fix(typing): Add `@sentry_sdk.traces.trace` overloads to fix typing (#6236) Changing `sentry_sdk.trace` to `sentry_sdk.traces.trace` introduced a whole avalanche of [typing issues](https://github.com/getsentry/seer/actions/runs/25495762136/job/74814929830?pr=6291). Adding the overloads we have for the original `trace`. --- sentry_sdk/traces.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 7f3997438f..38cae6945b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -37,6 +37,7 @@ Callable, Iterator, Optional, + overload, ParamSpec, TypeVar, Union, @@ -703,6 +704,23 @@ def timestamp(self) -> "Optional[datetime]": return None +if TYPE_CHECKING: + + @overload + def trace( + func: "Callable[P, R]", + ) -> "Callable[P, R]": ... + + @overload + def trace( + func: None = None, + *, + name: "Optional[str]" = None, + attributes: "Optional[dict[str, Any]]" = None, + active: bool = True, + ) -> "Callable[[Callable[P, R]], Callable[P, R]]": ... + + def trace( func: "Optional[Callable[P, R]]" = None, *, From 2644d94e679fac58c6d0b8a001a031cb97eb7cdf Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 7 May 2026 16:02:40 +0200 Subject: [PATCH 11/11] ref: Rename `_timestamp` to `_end_timestamp` (#6235) The field capturing the end timestamp of a span used to be called `timestamp` in legacy mode (on the `Span` class), with `start_timestamp` as its counterpart. In span streaming, we originally followed the same convention on the new `StreamedSpan` class, but there's actually no reason to and it'd be better to use `end_timestamp` instead: - the field on the serialized span v2 is actually called `end_timestamp` - `end_timestamp` is a clearer name in general, and complements `start_timestamp` nicely --- sentry_sdk/_span_batcher.py | 4 ++-- sentry_sdk/traces.py | 20 +++++++++---------- sentry_sdk/tracing_utils.py | 18 +++++++++++------ .../sqlalchemy/test_sqlalchemy.py | 10 +++++++--- tests/integrations/stdlib/test_httplib.py | 8 ++++---- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/sentry_sdk/_span_batcher.py b/sentry_sdk/_span_batcher.py index 762b377af9..275462b21c 100644 --- a/sentry_sdk/_span_batcher.py +++ b/sentry_sdk/_span_batcher.py @@ -168,8 +168,8 @@ def _to_transport_format(item: "StreamedSpan") -> "Any": "start_timestamp": item._start_timestamp.timestamp(), } - if item._timestamp: - res["end_timestamp"] = item._timestamp.timestamp() + if item._end_timestamp: + res["end_timestamp"] = item._end_timestamp.timestamp() if item._parent_span_id: res["parent_span_id"] = item._parent_span_id diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index 38cae6945b..f49760f03b 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -245,7 +245,7 @@ class StreamedSpan: "_parent_sampled", "_start_timestamp", "_start_timestamp_monotonic_ns", - "_timestamp", + "_end_timestamp", "_status", "_scope", "_previous_span_on_scope", @@ -292,7 +292,7 @@ def __init__( self._sample_rate = sample_rate self._start_timestamp = datetime.now(timezone.utc) - self._timestamp: "Optional[datetime]" = None + self._end_timestamp: "Optional[datetime]" = None # profiling depends on this value and requires that # it is measured in nanoseconds @@ -328,7 +328,7 @@ def __enter__(self) -> "StreamedSpan": def __exit__( self, ty: "Optional[Any]", value: "Optional[Any]", tb: "Optional[Any]" ) -> None: - if self._timestamp is not None: + if self._end_timestamp is not None: # This span is already finished, ignore return @@ -362,7 +362,7 @@ def _start(self) -> None: self._previous_span_on_scope = old_span def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None: - if self._timestamp is not None: + if self._end_timestamp is not None: # This span is already finished, ignore. return @@ -393,15 +393,15 @@ def _end(self, end_timestamp: "Optional[Union[float, datetime]]" = None) -> None pass if isinstance(end_timestamp, datetime): - self._timestamp = end_timestamp + self._end_timestamp = end_timestamp else: logger.debug( "[Tracing] Failed to set end_timestamp. Using current time instead." ) - if self._timestamp is None: + if self._end_timestamp is None: elapsed = nanosecond_time() - self._start_timestamp_monotonic_ns - self._timestamp = self._start_timestamp + timedelta( + self._end_timestamp = self._start_timestamp + timedelta( microseconds=elapsed / 1000 ) @@ -480,8 +480,8 @@ def start_timestamp(self) -> "Optional[datetime]": return self._start_timestamp @property - def timestamp(self) -> "Optional[datetime]": - return self._timestamp + def end_timestamp(self) -> "Optional[datetime]": + return self._end_timestamp def _is_segment(self) -> bool: return self._segment is self @@ -700,7 +700,7 @@ def start_timestamp(self) -> "Optional[datetime]": return None @property - def timestamp(self) -> "Optional[datetime]": + def end_timestamp(self) -> "Optional[datetime]": return None diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 33b1a33814..8986759885 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -399,9 +399,12 @@ def add_query_source( if not should_add_query_source: return - end_timestamp = ( - datetime.now(timezone.utc) if span.timestamp is None else span.timestamp - ) + if isinstance(span, StreamedSpan): + end_timestamp = span.end_timestamp + else: + end_timestamp = span.timestamp + + end_timestamp = end_timestamp or datetime.now(timezone.utc) duration = end_timestamp - span.start_timestamp threshold = client.options.get("db_query_source_threshold_ms", 0) @@ -442,9 +445,12 @@ def add_http_request_source( if not should_add_request_source: return - end_timestamp = ( - datetime.now(timezone.utc) if span.timestamp is None else span.timestamp - ) + if isinstance(span, StreamedSpan): + end_timestamp = span.end_timestamp + else: + end_timestamp = span.timestamp + + end_timestamp = end_timestamp or datetime.now(timezone.utc) duration = end_timestamp - span.start_timestamp threshold = client.options.get("http_request_source_threshold_ms", 0) diff --git a/tests/integrations/sqlalchemy/test_sqlalchemy.py b/tests/integrations/sqlalchemy/test_sqlalchemy.py index 72c50a7f9a..d942d5fea3 100644 --- a/tests/integrations/sqlalchemy/test_sqlalchemy.py +++ b/tests/integrations/sqlalchemy/test_sqlalchemy.py @@ -939,7 +939,9 @@ def __init__(self, *args, **kwargs): if span_streaming: self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span._timestamp = datetime(2024, 1, 1, microsecond=99999) + self.span._end_timestamp = datetime( + 2024, 1, 1, microsecond=99999 + ) else: self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) @@ -1003,7 +1005,9 @@ def __init__(self, *args, **kwargs): if span_streaming: self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span._timestamp = datetime(2024, 1, 1, microsecond=99999) + self.span._end_timestamp = datetime( + 2024, 1, 1, microsecond=99999 + ) else: self.span.start_timestamp = datetime(2024, 1, 1, microsecond=0) self.span.timestamp = datetime(2024, 1, 1, microsecond=99999) @@ -1082,7 +1086,7 @@ def __init__(self, *args, **kwargs): self.span = span self.span._start_timestamp = datetime(2024, 1, 1, microsecond=0) - self.span._timestamp = datetime(2024, 1, 1, microsecond=101000) + self.span._end_timestamp = datetime(2024, 1, 1, microsecond=101000) def __enter__(self): return self.span diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index 589a8e8e97..b93b0c840c 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -873,9 +873,9 @@ def test_no_request_source_if_duration_too_short( def add_http_request_source_with_pinned_timestamps(span): if span_streaming: span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span._timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) + span._end_timestamp = datetime.datetime(2024, 1, 1, microsecond=99999) result = add_http_request_source(span) - span._timestamp = None + span._end_timestamp = None return result else: span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) @@ -947,9 +947,9 @@ def test_request_source_if_duration_over_threshold( def add_http_request_source_with_pinned_timestamps(span): if span_streaming: span._start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0) - span._timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) + span._end_timestamp = datetime.datetime(2024, 1, 1, microsecond=100001) result = add_http_request_source(span) - span._timestamp = None + span._end_timestamp = None return result else: span.start_timestamp = datetime.datetime(2024, 1, 1, microsecond=0)