Skip to content

feat(span-first): Support before_send_span#6239

Draft
sentrivana wants to merge 10 commits intomasterfrom
ivana/span-first-before-send-span
Draft

feat(span-first): Support before_send_span#6239
sentrivana wants to merge 10 commits intomasterfrom
ivana/span-first-before-send-span

Conversation

@sentrivana
Copy link
Copy Markdown
Contributor

@sentrivana sentrivana commented May 8, 2026

Description

Add support for before_send_span in span streaming mode.

before_send_span is different from before_send_metric and before_send_log in that:

  • it doesn't allow users to drop a span (i.e., return None)
  • it only allows to modify specific parts of the span (name and attributes)

To that end, we're exposing a copy of the span in the callback instead of the real thing. Alternatively, we could just expose a simplified dictionary snapshot of the span ({"name": "name", "attributes": {}}), but that'd then bypass any extra logic we have in the span public API (like set_attribute(), which currently stringifies values if not serializable).

See https://develop.sentry.dev/sdk/telemetry/spans/scrubbing-data/ for spec.

TODO

  • Looks like we have some unserializable stuff on spans. Need to figure out what it is and why it's there. If we can't get rid of it it might need a stripped down version of the span or a snapshot after all

Issues

@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 8, 2026

PY-2057

@sentrivana sentrivana changed the title feat(span-first): Support before_send_span feat(span-first): Support before_send_span May 8, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Codecov Results 📊

2276 passed | ❌ 3 failed | ⏭️ 261 skipped | Total: 2540 | Pass Rate: 89.61% | Execution Time: 6m 30s

❌ Failed Tests

test_weight_based_flushing_by_attribute_size

File: tests.tracing.test_span_batcher
Suite: py3.8-gevent
Error: TypeError: 'StreamedSpan' object is not subscriptable

Stack Trace
tests/tracing/test_span_batcher.py:239: in test_weight_based_flushing_by_attribute_size
    bare_span_size = SpanBatcher._estimate_size(bare_span)
sentry_sdk/_span_batcher.py:151: in _estimate_size
    for value in item["attributes"].values():
E   TypeError: 'StreamedSpan' object is not subscriptable

test_before_send_span_invalid_return_value[return_value2]

File: tests.tracing.test_span_streaming
Suite: py3.8-gevent
Error: KeyError: 'trace_id'

Stack Trace
tests/tracing/test_span_streaming.py:339: in test_before_send_span_invalid_return_value
    ...
sentry_sdk/traces.py:338: in __exit__
    self._end()
sentry_sdk/traces.py:413: in _end
    self._scope._capture_span(self)
sentry_sdk/scope.py:1471: in _capture_span
    client._capture_span(span, scope=merged_scope)
sentry_sdk/client.py:1009: in _capture_span
    self._capture_telemetry(span, "span", scope)
sentry_sdk/client.py:1000: in _capture_telemetry
    batcher.add(serialized)  # type: ignore
sentry_sdk/_span_batcher.py:118: in add
    size = len(self._span_buffer[span["trace_id"]])
E   KeyError: 'trace_id'

test_before_send_span_unsupported_edit

File: tests.tracing.test_span_streaming
Suite: py3.8-gevent
Error: AssertionError: assert 'my-trace-id' != 'my-trace-id'

Stack Trace
tests/tracing/test_span_streaming.py:377: in test_before_send_span_unsupported_edit
    assert span["trace_id"] != "my-trace-id"
E   AssertionError: assert 'my-trace-id' != 'my-trace-id'

✅ Patch coverage is 95.45%. Project has 12862 uncovered lines.

Files with missing lines (6)
File Patch % Lines
utils.py 83.88% ⚠️ 151 Missing and 48 partials
client.py 85.30% ⚠️ 81 Missing and 41 partials
traces.py 87.23% ⚠️ 41 Missing and 20 partials
_span_batcher.py 85.83% ⚠️ 17 Missing and 7 partials
_types.py 76.19% ⚠️ 10 Missing and 1 partials
consts.py 99.22% ⚠️ 2 Missing

Generated by Codecov Action

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

Codecov Results 📊

44 passed | ❌ 13 failed | Total: 57 | Pass Rate: 77.19% | Execution Time: 9.62s

❌ Failed Tests

test_tracing_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:42227/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1159: in test_tracing_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:42227/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_sensitive_header_scrubbing_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:43849/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1227: in test_sensitive_header_scrubbing_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:43849/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_sensitive_header_passthrough_with_pii_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: ValueError: not enough values to unpack (expected 1, got 0)

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1273: in test_sensitive_header_passthrough_with_pii_span_streaming
    (server_span,) = [item.payload for item in items]
E   ValueError: not enough values to unpack (expected 1, got 0)

test_request_body_captured_on_segment_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:37321/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1311: in test_request_body_captured_on_segment_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:37321/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_request_body_not_read_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:45665/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1341: in test_request_body_not_read_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:45665/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:13 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_request_body_over_size_limit_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:44169/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1375: in test_request_body_over_size_limit_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:44169/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_url_query_attribute_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:44099/?foo=bar&baz=qux) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1407: in test_url_query_attribute_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:44099/?foo=bar&baz=qux) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_transaction_style_span_streaming[pyloop-/message-handler_name-tests.integrations.aiohttp.test_aiohttp.test_transaction_style_span_streaming.<locals>.hello-component]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:42867/message) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1461: in test_transaction_style_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:42867/message) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_transaction_style_span_streaming[pyloop-/message-method_and_path_pattern-GET /{var}-route]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 200 + where 500 = <ClientResponse(http://127.0.0.1:36003/message) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1461: in test_transaction_style_span_streaming
    assert resp.status == 200
E   AssertionError: assert 500 == 200
E    +  where 500 = <ClientResponse(http://127.0.0.1:36003/message) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:14 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_server_error_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: assert 2 == 3 + where 2 = len([UnwrappedItem(type='event', payload={'level': 'error', 'exception': {'values': [{'mechanism': {'type': 'aiohttp', 'handled': False}, 'module': None, 'type': 'ZeroDivisionError', 'value': 'division by zero', 'stacktrace': {'frames': [{'filename': 'aiohttp/web_app.py', 'abs_path': '/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/aiohttp/web_app.py', 'function': '_handle', 'module': 'aiohttp.web_app', 'lineno': 499, 'pre_context': [' partial(m, handler=handler), handler', ' )', ' else:', ' handler = await m(app, handler) # type: ignore', ''], 'context_line': ' resp = await handler(request)', 'post_context': ['', ' return resp', '', ' def call(self) -> "Application":', ' """gunicorn compatibility"""'], 'vars': {'self': {}, 'request': {}, 'loop': '<_UnixSelectorEventLoop running=True closed=False debug=False>', 'debug': 'False', 'match_info': {}, 'resp': 'None', 'expect': 'None', 'handler': '<function test_server_error_span_streaming..hello at 0x7f1991790430>'}, 'in_app': False}, {'filename': 'tests/integrations/aiohttp/test_aiohttp.py', 'abs_path': '/home/runner/work/sentry-python/sentry-python/tests/integrations/aiohttp/test_aiohttp.py', 'function': 'hello', 'module': 'tests.integrations.aiohttp.test_aiohttp', 'lineno': 1482, 'pre_context': [' traces_sample_rate=1.0,', ' _experiments={"trace_lifecycle": "stream"},', ' )', '', ' async def hello(request):'], 'context_line': ' 1 / 0', 'post_context': ['', ' app = web.Application()', ' app.router.add_get("/", hello)', '', ' items = capture_items("event", "span")'], 'vars': {'request': {}}, 'in_app': True}]}}]}, 'event_id': '595dd3acd8fb47ceb8b815b13297740f', 'timestamp': '2026-05-08T13:12:14.841597Z', 'contexts': {'trace': {'trace_id': '357daa0339ac4d38a585a0b806ac6cb2', 'span_id': 'b1e93bf9c589f2e8', 'parent_span_id': 'ab82f4ee25431eae', 'op': 'http.server', 'origin': 'auto.http.aiohttp'}, 'runtime': {'name': 'CPython', 'version': '3.9.25', 'build': '3.9.25 (main, Nov 3 2025, 15:14:54) \n[GCC 11.4.0]'}}, 'transaction': 'tests.integrations.aiohttp.test_aiohttp.test_server_error_span_streaming..hello', 'transaction_info': {'source': <TransactionSource.COMPONENT: 'component'>}, 'breadcrumbs': {'values': []}, 'extra': {'sys.argv': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/main.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4']}, 'modules': {'sentry-sdk': '2.59.0', 'pytest-aiohttp': '0.3.0', 'multidict': '6.7.1', 'iniconfig': '2.1.0', 'pytest-forked': '1.6.0', 'charset-normalizer': '3.4.7', 'jsonschema': '4.25.1', 'wheel': '0.43.0', 'propcache': '0.4.1', 'tomli': '2.4.1', 'pyyaml': '6.0.3', 'coverage': '7.10.7', 'pytest-localserver': '0.10.0', 'certifi': '2026.4.22', 'hyperframe': '6.1.0', 'colorama': '0.4.6', 'pygments': '2.20.0', 'markupsafe': '3.0.3', 'referencing': '0.36.2', 'pysocks': '1.7.1', 'setuptools': '69.5.1', 'httpcore': '1.0.9', 'urllib3': '2.6.3', 'yarl': '1.22.0', 'h2': '4.3.0', 'hpack': '4.1.0', 'pytest-timeout': '2.4.0', 'asttokens': '3.0.1', 'idna': '3.13', 'py': '1.11.0', 'async-timeout': '3.0.1', 'pytest-watch': '4.2.0', 'pluggy': '1.6.0', 'aiohttp': '3.7.4', 'watchdog': '6.0.0', 'brotli': '1.2.0', 'pytest-cov': '7.1.0', 'responses': '0.26.0', 'socksio': '1.0.0', 'pip': '24.0', 'requests': '2.32.5', 'docopt': '0.6.2', 'typing_extensions': '4.15.0', 'docker': '7.1.0', 'executing': '2.2.1', 'werkzeug': '3.1.8', 'jsonschema-specifications': '2025.9.1', 'h11': '0.16.0', 'packaging': '26.2', 'chardet': '3.0.4', 'attrs': '26.1.0', 'rpds-py': '0.27.1', 'pytest': '8.4.2', 'exceptiongroup': '1.3.1'}, 'request': {'url': 'http://127.0.0.1:41929/', 'query_string': '', 'method': 'GET', 'env': {'REMOTE_ADDR': '127.0.0.1'}, 'headers': {'Host': '127.0.0.1:41929', 'sentry-trace': '357daa0339ac4d38a585a0b806ac6cb2-ab82f4ee25431eae-1', 'baggage': 'sentry-trace_id=357daa0339ac4d38a585a0b806ac6cb2,sentry-sample_rand=0.034682,sentry-environment=production,sentry-release=fe5b14821f6fcc5acfdab3744c00cc8d3876e65f,sentry-transaction=GET%20http%3A//127.0.0.1%3A41929/,sentry-sample_rate=1.0,sentry-sampled=true', 'Accept': '/', 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'Python/3.9 aiohttp/3.7.4'}, 'data': None}, 'release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f', 'environment': 'production', 'server_name': 'runnervmrc6n4', 'sdk': {'name': 'sentry.python.aiohttp', 'version': '2.59.0', 'packages': [{'name': 'pypi:sentry-sdk', 'version': '2.59.0'}], 'integrations': ['aiohttp', 'argv', 'atexit', 'dedupe', 'excepthook', 'logging', 'modules', 'stdlib', 'threading']}, 'platform': 'python'}), UnwrappedItem(type='span', payload={'trace_id': '357daa0339ac4d38a585a0b806ac6cb2', 'span_id': 'ab82f4ee25431eae', 'name': 'GET http://127.0.0.1:41929/', 'status': 'error', 'is_segment': True, 'start_timestamp': 1778245934.837718, 'end_timestamp': 1778245934.84913, 'attributes': {'sentry.origin': 'auto.http.aiohttp', 'sentry.op': 'http.client', 'http.request.method': 'GET', 'url.full': 'http://127.0.0.1:41929/', 'thread.id': '139747850922880', 'thread.name': 'MainThread', 'process.command_args': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/main.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4'], 'http.response.status_code': 500, 'sentry.segment.id': 'ab82f4ee25431eae', 'sentry.segment.name': 'GET http://127.0.0.1:41929/', 'sentry.sdk.name': 'sentry.python.aiohttp', 'sentry.sdk.version': '2.59.0', 'server.address': 'runnervmrc6n4', 'sentry.environment': 'production', 'sentry.release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f'}})])

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1496: in test_server_error_span_streaming
    assert len(items) == 3
E   assert 2 == 3
E    +  where 2 = len([UnwrappedItem(type='event', payload={'level': 'error', 'exception': {'values': [{'mechanism': {'type': 'aiohttp', 'handled': False}, 'module': None, 'type': 'ZeroDivisionError', 'value': 'division by zero', 'stacktrace': {'frames': [{'filename': 'aiohttp/web_app.py', 'abs_path': '/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/aiohttp/web_app.py', 'function': '_handle', 'module': 'aiohttp.web_app', 'lineno': 499, 'pre_context': ['                                partial(m, handler=handler), handler', '                            )', '                        else:', '                            handler = await m(app, handler)  # type: ignore', ''], 'context_line': '            resp = await handler(request)', 'post_context': ['', '        return resp', '', '    def __call__(self) -> "Application":', '        """gunicorn compatibility"""'], 'vars': {'self': {}, 'request': {}, 'loop': '<_UnixSelectorEventLoop running=True closed=False debug=False>', 'debug': 'False', 'match_info': {}, 'resp': 'None', 'expect': 'None', 'handler': '<function test_server_error_span_streaming.<locals>.hello at 0x7f1991790430>'}, 'in_app': False}, {'filename': 'tests/integrations/aiohttp/test_aiohttp.py', 'abs_path': '/home/runner/work/sentry-python/sentry-python/tests/integrations/aiohttp/test_aiohttp.py', 'function': 'hello', 'module': 'tests.integrations.aiohttp.test_aiohttp', 'lineno': 1482, 'pre_context': ['        traces_sample_rate=1.0,', '        _experiments={"trace_lifecycle": "stream"},', '    )', '', '    async def hello(request):'], 'context_line': '        1 / 0', 'post_context': ['', '    app = web.Application()', '    app.router.add_get("/", hello)', '', '    items = capture_items("event", "span")'], 'vars': {'request': {}}, 'in_app': True}]}}]}, 'event_id': '595dd3acd8fb47ceb8b815b13297740f', 'timestamp': '2026-05-08T13:12:14.841597Z', 'contexts': {'trace': {'trace_id': '357daa0339ac4d38a585a0b806ac6cb2', 'span_id': 'b1e93bf9c589f2e8', 'parent_span_id': 'ab82f4ee25431eae', 'op': 'http.server', 'origin': 'auto.http.aiohttp'}, 'runtime': {'name': 'CPython', 'version': '3.9.25', 'build': '3.9.25 (main, Nov  3 2025, 15:14:54) \n[GCC 11.4.0]'}}, 'transaction': 'tests.integrations.aiohttp.test_aiohttp.test_server_error_span_streaming.<locals>.hello', 'transaction_info': {'source': <TransactionSource.COMPONENT: 'component'>}, 'breadcrumbs': {'values': []}, 'extra': {'sys.argv': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/__main__.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4']}, 'modules': {'sentry-sdk': '2.59.0', 'pytest-aiohttp': '0.3.0', 'multidict': '6.7.1', 'iniconfig': '2.1.0', 'pytest-forked': '1.6.0', 'charset-normalizer': '3.4.7', 'jsonschema': '4.25.1', 'wheel': '0.43.0', 'propcache': '0.4.1', 'tomli': '2.4.1', 'pyyaml': '6.0.3', 'coverage': '7.10.7', 'pytest-localserver': '0.10.0', 'certifi': '2026.4.22', 'hyperframe': '6.1.0', 'colorama': '0.4.6', 'pygments': '2.20.0', 'markupsafe': '3.0.3', 'referencing': '0.36.2', 'pysocks': '1.7.1', 'setuptools': '69.5.1', 'httpcore': '1.0.9', 'urllib3': '2.6.3', 'yarl': '1.22.0', 'h2': '4.3.0', 'hpack': '4.1.0', 'pytest-timeout': '2.4.0', 'asttokens': '3.0.1', 'idna': '3.13', 'py': '1.11.0', 'async-timeout': '3.0.1', 'pytest-watch': '4.2.0', 'pluggy': '1.6.0', 'aiohttp': '3.7.4', 'watchdog': '6.0.0', 'brotli': '1.2.0', 'pytest-cov': '7.1.0', 'responses': '0.26.0', 'socksio': '1.0.0', 'pip': '24.0', 'requests': '2.32.5', 'docopt': '0.6.2', 'typing_extensions': '4.15.0', 'docker': '7.1.0', 'executing': '2.2.1', 'werkzeug': '3.1.8', 'jsonschema-specifications': '2025.9.1', 'h11': '0.16.0', 'packaging': '26.2', 'chardet': '3.0.4', 'attrs': '26.1.0', 'rpds-py': '0.27.1', 'pytest': '8.4.2', 'exceptiongroup': '1.3.1'}, 'request': {'url': 'http://127.0.0.1:41929/', 'query_string': '', 'method': 'GET', 'env': {'REMOTE_ADDR': '127.0.0.1'}, 'headers': {'Host': '127.0.0.1:41929', 'sentry-trace': '357daa0339ac4d38a585a0b806ac6cb2-ab82f4ee25431eae-1', 'baggage': 'sentry-trace_id=357daa0339ac4d38a585a0b806ac6cb2,sentry-sample_rand=0.034682,sentry-environment=production,sentry-release=fe5b14821f6fcc5acfdab3744c00cc8d3876e65f,sentry-transaction=GET%20http%3A//127.0.0.1%3A41929/,sentry-sample_rate=1.0,sentry-sampled=true', 'Accept': '*/*', 'Accept-Encoding': 'gzip, deflate', 'User-Agent': 'Python/3.9 aiohttp/3.7.4'}, 'data': None}, 'release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f', 'environment': 'production', 'server_name': 'runnervmrc6n4', 'sdk': {'name': 'sentry.python.aiohttp', 'version': '2.59.0', 'packages': [{'name': 'pypi:sentry-sdk', 'version': '2.59.0'}], 'integrations': ['aiohttp', 'argv', 'atexit', 'dedupe', 'excepthook', 'logging', 'modules', 'stdlib', 'threading']}, 'platform': 'python'}), UnwrappedItem(type='span', payload={'trace_id': '357daa0339ac4d38a585a0b806ac6cb2', 'span_id': 'ab82f4ee25431eae', 'name': 'GET http://127.0.0.1:41929/', 'status': 'error', 'is_segment': True, 'start_timestamp': 1778245934.837718, 'end_timestamp': 1778245934.84913, 'attributes': {'sentry.origin': 'auto.http.aiohttp', 'sentry.op': 'http.client', 'http.request.method': 'GET', 'url.full': 'http://127.0.0.1:41929/', 'thread.id': '139747850922880', 'thread.name': 'MainThread', 'process.command_args': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/__main__.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4'], 'http.response.status_code': 500, 'sentry.segment.id': 'ab82f4ee25431eae', 'sentry.segment.name': 'GET http://127.0.0.1:41929/', 'sentry.sdk.name': 'sentry.python.aiohttp', 'sentry.sdk.version': '2.59.0', 'server.address': 'runnervmrc6n4', 'sentry.environment': 'production', 'sentry.release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f'}})])

test_http_exception_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 403 + where 500 = <ClientResponse(http://127.0.0.1:33723/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:15 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1542: in test_http_exception_span_streaming
    assert resp.status == 403
E   AssertionError: assert 500 == 403
E    +  where 500 = <ClientResponse(http://127.0.0.1:33723/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:15 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_http_exception_ok_status_not_overridden_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 500 == 302 + where 500 = <ClientResponse(http://127.0.0.1:41277/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:15 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1580: in test_http_exception_ok_status_not_overridden_span_streaming
    assert resp.status == 302
E   AssertionError: assert 500 == 302
E    +  where 500 = <ClientResponse(http://127.0.0.1:41277/) [500 Internal Server Error]>\n<CIMultiDictProxy('Content-Type': 'text/plain; charset=utf-8', 'Content-Length': '55', 'Date': 'Fri, 08 May 2026 13:12:15 GMT', 'Server': 'Python/3.9 aiohttp/3.7.4', 'Connection': 'close')>\n.status

test_outgoing_client_span_span_streaming[pyloop]

File: tests.integrations.aiohttp.test_aiohttp
Suite: py3.9-aiohttp-v3.7.4
Error: AssertionError: assert 1 == 3 + where 1 = len([UnwrappedItem(type='span', payload={'trace_id': '96650ac078bc44d3a53c8b0a24c75a6c', 'span_id': '9bce2b81809406c2', 'name': 'GET http://127.0.0.1:41523/', 'status': 'error', 'is_segment': True, 'start_timestamp': 1778245935.378089, 'end_timestamp': 1778245935.623357, 'attributes': {'sentry.origin': 'auto.http.aiohttp', 'sentry.op': 'http.client', 'http.request.method': 'GET', 'url.full': 'http://127.0.0.1:41523/', 'thread.id': '139747850922880', 'thread.name': 'MainThread', 'process.command_args': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/main.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4'], 'http.response.status_code': 500, 'code.line.number': 1624, 'code.namespace': 'tests.integrations.aiohttp.test_aiohttp', 'code.file.path': 'tests/integrations/aiohttp/test_aiohttp.py', 'code.function': 'test_outgoing_client_span_span_streaming', 'sentry.segment.id': '9bce2b81809406c2', 'sentry.segment.name': 'GET http://127.0.0.1:41523/', 'sentry.sdk.name': 'sentry.python.aiohttp', 'sentry.sdk.version': '2.59.0', 'server.address': 'runnervmrc6n4', 'sentry.environment': 'production', 'sentry.release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f'}})])

Stack Trace
tests/integrations/aiohttp/test_aiohttp.py:1632: in test_outgoing_client_span_span_streaming
    assert len(items) == 3
E   AssertionError: assert 1 == 3
E    +  where 1 = len([UnwrappedItem(type='span', payload={'trace_id': '96650ac078bc44d3a53c8b0a24c75a6c', 'span_id': '9bce2b81809406c2', 'name': 'GET http://127.0.0.1:41523/', 'status': 'error', 'is_segment': True, 'start_timestamp': 1778245935.378089, 'end_timestamp': 1778245935.623357, 'attributes': {'sentry.origin': 'auto.http.aiohttp', 'sentry.op': 'http.client', 'http.request.method': 'GET', 'url.full': 'http://127.0.0.1:41523/', 'thread.id': '139747850922880', 'thread.name': 'MainThread', 'process.command_args': ['/home/runner/work/sentry-python/sentry-python/.tox/py3.9-aiohttp-v3.7.4/lib/python3.9/site-packages/pytest/__main__.py', '-W', 'error::pytest.PytestUnraisableExceptionWarning', 'tests/integrations/aiohttp', '-o', 'junit_suite_name=py3.9-aiohttp-v3.7.4'], 'http.response.status_code': 500, 'code.line.number': 1624, 'code.namespace': 'tests.integrations.aiohttp.test_aiohttp', 'code.file.path': 'tests/integrations/aiohttp/test_aiohttp.py', 'code.function': 'test_outgoing_client_span_span_streaming', 'sentry.segment.id': '9bce2b81809406c2', 'sentry.segment.name': 'GET http://127.0.0.1:41523/', 'sentry.sdk.name': 'sentry.python.aiohttp', 'sentry.sdk.version': '2.59.0', 'server.address': 'runnervmrc6n4', 'sentry.environment': 'production', 'sentry.release': 'fe5b14821f6fcc5acfdab3744c00cc8d3876e65f'}})])

❌ Patch coverage is 45.00%. Project has 16033 uncovered lines.

Files with missing lines (3)
File Patch % Lines
utils.py 54.32% ⚠️ 428 Missing and 90 partials
client.py 61.82% ⚠️ 210 Missing and 78 partials
consts.py 99.22% ⚠️ 2 Missing

Generated by Codecov Action

# estimate the attributes separately.
estimate = 210
for value in item._attributes.values():
for value in item["attributes"].values():
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_estimate_size raises KeyError when span lacks attributes

_estimate_size accesses item["attributes"].values() unconditionally, but 'attributes' is NotRequired in SpanJSON. The new before_send_span feature allows users to return a modified dict. While the intent is that invalid returns should fall back to the original span, the validation at client.py:979 only checks isinstance(serialized, dict) and serialized (non-empty). This allows dicts like {"not_a_span": True} to pass validation. When such a dict reaches _span_batcher.add(), it will raise KeyError in _estimate_size at line 151, causing the span to be dropped and potentially propagating the exception.

Verification

Read client.py and confirmed before_send_span handling at lines 964-986. Line 979 validation only checks for non-empty dict, with a TODO comment at line 980 acknowledging missing validation. SpanJSON TypedDict in _types.py line 331 marks attributes as NotRequired. Test at test_span_streaming.py line 318 shows before_send_span can return {} or {"not_a_span": True}. Empty dict {} is caught by falsy check, but {"not_a_span": True} passes through. Confirmed _estimate_size in _span_batcher.py line 151 accesses attributes unconditionally. No defensive code exists between client.py validation and _estimate_size call.

Also found at 2 additional locations
  • sentry_sdk/_span_batcher.py:165-170
  • sentry_sdk/client.py:978-986

Identified by Warden find-bugs · VPT-R9G

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add before_send_span

1 participant