Skip to content

feat(span-first): Support before_send_span#6239

Merged
sentrivana merged 25 commits into
masterfrom
ivana/span-first-before-send-span
May 18, 2026
Merged

feat(span-first): Support before_send_span#6239
sentrivana merged 25 commits into
masterfrom
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

To that end, we're now serializing the span earlier, and exposing the serialized dictionary in the before_send callback. This is consistent with metrics and logs. It also means we're now queuing dictionaries instead of StreamedSpan instances in the span batcher, which should also decrease our memory footprint.

This aligns our implementation with JS.

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

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 📊

282 passed | Total: 282 | Pass Rate: 100% | Execution Time: 41.95s

📊 Comparison with Base Branch

Metric Change
Total Tests
Passed Tests
Failed Tests
Skipped Tests

✨ No test changes detected

All tests are passing successfully.

❌ Patch coverage is 15.22%. Project has 14797 uncovered lines.
❌ Project coverage is 33.6%. Comparing base (base) to head (head).

Files with missing lines (6)
File Patch % Lines
utils.py 52.35% ⚠️ 447 Missing and 82 partials
client.py 57.67% ⚠️ 287 Missing and 89 partials
traces.py 35.83% ⚠️ 206 Missing
_span_batcher.py 23.33% ⚠️ 92 Missing
_types.py 66.67% ⚠️ 14 Missing
consts.py 99.48% ⚠️ 2 Missing
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
- Coverage    33.63%    33.60%    -0.03%
==========================================
  Files          190       190         —
  Lines        22260     22284       +24
  Branches      7536      7548       +12
==========================================
+ Hits          7485      7487        +2
- Misses       14775     14797       +22
- Partials       747       747         —

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

Comment thread sentry_sdk/_span_batcher.py Outdated
Comment thread sentry_sdk/client.py Outdated
Comment thread sentry_sdk/client.py Outdated
Comment thread sentry_sdk/client.py Outdated
Comment thread sentry_sdk/client.py
Comment thread sentry_sdk/client.py
if serialized is None:
return

elif ty == "span" and isinstance(telemetry, StreamedSpan):
Copy link
Copy Markdown
Contributor Author

@sentrivana sentrivana May 13, 2026

Choose a reason for hiding this comment

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

The isinstance(telemetry, StreamedSpan) part of the condition is only there because mypy was complaining :( It can't deal with dispatchers/generics very well in general.

class fake_record_sql_queries: # noqa: N801
def __init__(self, *args, **kwargs):
with record_sql_queries_supporting_streaming(
self._ctx_mgr = record_sql_queries_supporting_streaming(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Had to change this test because it was failing -- it was closing the span too early.

@sentrivana sentrivana marked this pull request as ready for review May 13, 2026 11:24
@sentrivana sentrivana requested a review from a team as a code owner May 13, 2026 11:24
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit e65b1c2. Configure here.

Comment thread sentry_sdk/_span_batcher.py
Comment thread sentry_sdk/utils.py
Copy link
Copy Markdown
Contributor

@alexander-alderman-webb alexander-alderman-webb left a comment

Choose a reason for hiding this comment

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

Looks good!

Copy link
Copy Markdown
Member

@ericapisani ericapisani left a comment

Choose a reason for hiding this comment

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

LGTM to me overall, just saw a few polishing opportunities. Approving so as not to block :shipit:

# estimate the attributes separately.
estimate = 210
for value in item._attributes.values():
for value in (item.get("attributes") or {}).values():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This can be made slightly more concise by doing the following:

Suggested change
for value in (item.get("attributes") or {}).values():
for value in (item.get("attributes", {})).values():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

If item["attributes"] exists and is None, then item.get("attributes", {}) would return None and the subsequent .values() call would fail.

FWIW I don't think attributes can be None at this point, but wrote it that way just to be extra safe.

Comment thread sentry_sdk/client.py Outdated
Comment thread sentry_sdk/client.py
if isinstance(serialized, dict) and serialized and "name" in serialized:
telemetry.name = serialized["name"] # type: ignore[typeddict-item]
telemetry._attributes = {}
for k, v in (serialized.get("attributes") or {}).items():
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Similar to my comment above, you can remove the or by adding the {} as the fallback on the get:

Suggested change
for k, v in (serialized.get("attributes") or {}).items():
for k, v in (serialized.get("attributes", {})).items():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same concern as above -- there's a case where the two are not equivalent and I'd opt for the more paranoid version

Comment thread sentry_sdk/client.py Outdated
Comment thread sentry_sdk/client.py
Comment on lines +273 to +280
def test_before_send_span_basic(sentry_init, capture_items):
def before_send_span(span, hint):
assert isinstance(span, dict)

span["name"] = "Better span name"
del span["attributes"]["drop"]
span["attributes"]["sanitize"] = "[Removed]"
span["attributes"]["add"] = "new"
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.

before_send_span exceptions propagate to user code via __exit__, unlike before_send for events

If a user's before_send_span callback raises, the exception propagates through StreamedSpan.__exit__, crashing the surrounding with block. The main before_send for events wraps the callback in capture_internal_exceptions(); _capture_telemetry in client.py does not — add a with capture_internal_exceptions(): guard there and add a test case here.

Verification

Checked sentry_sdk/client.py line 1199: serialized = before_send(serialized, {}) is called bare inside _capture_telemetry with no try/except or capture_internal_exceptions wrapper. The main event before_send at client.py:872 is protected: with capture_internal_exceptions(): new_event = before_send(event, hint or {}). The call path for spans is: StreamedSpan.__exit___end()scope._capture_span()client._capture_span()_capture_telemetry()before_send(serialized, {}). An exception escapes all the way back to user code in the with block. This is also true for before_send_log/before_send_metric (consistent pattern), but spans are called from context-manager __exit__, making the crash surface more user-visible. The four new tests never exercise a raising callback, so the gap is undetected.

Identified by Warden find-bugs · S67-F2P

@sentrivana sentrivana merged commit 5c65e68 into master May 18, 2026
156 checks passed
@sentrivana sentrivana deleted the ivana/span-first-before-send-span branch May 18, 2026 08:55
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

3 participants