Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a139626
feat(sqlalchemy): Support span streaming
alexander-alderman-webb Apr 24, 2026
38b5933
.
alexander-alderman-webb Apr 24, 2026
3d89b9e
.
alexander-alderman-webb Apr 24, 2026
b1fa3b5
.
alexander-alderman-webb Apr 24, 2026
9929f55
.
alexander-alderman-webb Apr 24, 2026
75c0cc1
.
alexander-alderman-webb Apr 24, 2026
ad4e14d
fix mypy
alexander-alderman-webb Apr 24, 2026
2bf7c77
.
alexander-alderman-webb Apr 24, 2026
f873d09
.
alexander-alderman-webb Apr 24, 2026
56ee084
add type ignores
alexander-alderman-webb Apr 24, 2026
2c22c16
move query source before exit
alexander-alderman-webb Apr 24, 2026
f9faa2f
.
alexander-alderman-webb Apr 24, 2026
064dd83
use separate path for streaming
alexander-alderman-webb Apr 24, 2026
4b965c4
use separate path for streaming
alexander-alderman-webb Apr 24, 2026
98c67f0
remove print
alexander-alderman-webb Apr 24, 2026
2877f7b
Merge branch 'master' into webb/sqlalchemy/span-first
alexander-alderman-webb Apr 27, 2026
f20dfc6
update tests
alexander-alderman-webb Apr 27, 2026
6322ad0
add missing code.namespace assertions
alexander-alderman-webb Apr 27, 2026
193f790
use non-deprecated attributes
alexander-alderman-webb Apr 28, 2026
4a7414e
use old attributes for legacy path
alexander-alderman-webb Apr 28, 2026
5efa060
.
alexander-alderman-webb Apr 28, 2026
639318f
consistently early exit
alexander-alderman-webb Apr 28, 2026
e71988a
use non-deprecated attributes in tests
alexander-alderman-webb Apr 28, 2026
ff174f8
stop setting undocumented attributes
alexander-alderman-webb Apr 28, 2026
47f5a6b
add comment
alexander-alderman-webb Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions sentry_sdk/integrations/asyncpg.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations
import contextlib
import re
from typing import Any, TypeVar, Callable, Awaitable, Iterator
from typing import Any, TypeVar, Callable, Awaitable, Iterator, Union

import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
Expand All @@ -13,6 +13,7 @@
parse_version,
capture_internal_exceptions,
)
from sentry_sdk.traces import StreamedSpan

try:
import asyncpg # type: ignore[import-not-found]
Expand Down Expand Up @@ -101,7 +102,7 @@
params_list: "tuple[Any, ...] | None",
*,
executemany: bool = False,
) -> "Iterator[Span]":
) -> "Iterator[Union[Span, StreamedSpan]]":

Check failure on line 105 in sentry_sdk/integrations/asyncpg.py

View check run for this annotation

@sentry/warden / warden: find-bugs

StreamedSpan lacks set_data method called in _wrap_cursor_creation

The type annotation change to `Iterator[Union[Span, StreamedSpan]]` reflects that `record_sql_queries` can return either type, but line 156 calls `span.set_data("db.cursor", res)` which will fail with AttributeError when span streaming is enabled. StreamedSpan only has `set_attribute()`, not `set_data()`. This causes a runtime crash when using asyncpg cursor operations with span streaming enabled.
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Outdated
integration = sentry_sdk.get_client().get_integration(AsyncPGIntegration)
if integration is not None and not integration._record_params:
params_list = None
Expand Down Expand Up @@ -197,22 +198,27 @@
return _inner


def _set_db_data(span: "Span", conn: "Any") -> None:
span.set_data(SPANDATA.DB_SYSTEM, "postgresql")
span.set_data(SPANDATA.DB_DRIVER_NAME, "asyncpg")
def _set_db_data(span: "Union[Span, StreamedSpan]", conn: "Any") -> None:
if isinstance(span, StreamedSpan):
set_on_span = span.set_attribute
else:
set_on_span = span.set_data

Check failure on line 205 in sentry_sdk/integrations/asyncpg.py

View check run for this annotation

@sentry/warden / warden: find-bugs

[KAV-9GR] StreamedSpan lacks set_data method called in _wrap_cursor_creation (additional location)

The type annotation change to `Iterator[Union[Span, StreamedSpan]]` reflects that `record_sql_queries` can return either type, but line 156 calls `span.set_data("db.cursor", res)` which will fail with AttributeError when span streaming is enabled. StreamedSpan only has `set_attribute()`, not `set_data()`. This causes a runtime crash when using asyncpg cursor operations with span streaming enabled.

set_on_span(SPANDATA.DB_SYSTEM, "postgresql")
set_on_span(SPANDATA.DB_DRIVER_NAME, "asyncpg")

addr = conn._addr
if addr:
try:
span.set_data(SPANDATA.SERVER_ADDRESS, addr[0])
span.set_data(SPANDATA.SERVER_PORT, addr[1])
set_on_span(SPANDATA.SERVER_ADDRESS, addr[0])
set_on_span(SPANDATA.SERVER_PORT, addr[1])
except IndexError:
pass

database = conn._params.database
if database:
span.set_data(SPANDATA.DB_NAME, database)
set_on_span(SPANDATA.DB_NAME, database)

user = conn._params.user
if user:
span.set_data(SPANDATA.DB_USER, user)
set_on_span(SPANDATA.DB_USER, user)
22 changes: 15 additions & 7 deletions sentry_sdk/integrations/django/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from sentry_sdk.scope import add_global_event_processor, should_send_default_pii
from sentry_sdk.serializer import add_global_repr_processor, add_repr_sequence_type
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing_utils import add_query_source, record_sql_queries
from sentry_sdk.utils import (
AnnotatedValue,
Expand Down Expand Up @@ -720,14 +721,21 @@ def _rollback(self: "BaseDatabaseWrapper") -> None:


def _set_db_data(
span: "Span", cursor_or_db: "Any", db_operation: "Optional[str]" = None
span: "Union[Span, StreamedSpan]",
cursor_or_db: "Any",
db_operation: "Optional[str]" = None,
) -> None:
if isinstance(span, StreamedSpan):
set_on_span = span.set_attribute
else:
set_on_span = span.set_data

db = cursor_or_db.db if hasattr(cursor_or_db, "db") else cursor_or_db
vendor = db.vendor
span.set_data(SPANDATA.DB_SYSTEM, vendor)
set_on_span(SPANDATA.DB_SYSTEM, vendor)

if db_operation is not None:
span.set_data(SPANDATA.DB_OPERATION, db_operation)
set_on_span(SPANDATA.DB_OPERATION, db_operation)

# Some custom backends override `__getattr__`, making it look like `cursor_or_db`
# actually has a `connection` and the `connection` has a `get_dsn_parameters`
Expand Down Expand Up @@ -760,19 +768,19 @@ def _set_db_data(

db_name = connection_params.get("dbname") or connection_params.get("database")
if db_name is not None:
span.set_data(SPANDATA.DB_NAME, db_name)
set_on_span(SPANDATA.DB_NAME, db_name)

server_address = connection_params.get("host")
if server_address is not None:
span.set_data(SPANDATA.SERVER_ADDRESS, server_address)
set_on_span(SPANDATA.SERVER_ADDRESS, server_address)

server_port = connection_params.get("port")
if server_port is not None:
span.set_data(SPANDATA.SERVER_PORT, str(server_port))
set_on_span(SPANDATA.SERVER_PORT, str(server_port))

server_socket_address = connection_params.get("unix_socket")
if server_socket_address is not None:
span.set_data(SPANDATA.SERVER_SOCKET_ADDRESS, server_socket_address)
set_on_span(SPANDATA.SERVER_SOCKET_ADDRESS, server_socket_address)


def add_template_context_repr_sequence() -> None:
Expand Down
24 changes: 17 additions & 7 deletions sentry_sdk/integrations/sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
ensure_integration_enabled,
parse_version,
)
from sentry_sdk.traces import StreamedSpan, SpanStatus

try:
from sqlalchemy.engine import Engine # type: ignore
Expand All @@ -20,6 +21,7 @@
from typing import Any
from typing import ContextManager
from typing import Optional
from typing import Union

from sentry_sdk.tracing import Span

Expand Down Expand Up @@ -96,7 +98,10 @@ def _handle_error(context: "Any", *args: "Any") -> None:
span: "Optional[Span]" = getattr(execution_context, "_sentry_sql_span", None)

if span is not None:
span.set_status(SPANSTATUS.INTERNAL_ERROR)
if isinstance(span, StreamedSpan):
span.status = SpanStatus.ERROR
else:
span.set_status(SPANSTATUS.INTERNAL_ERROR)
Comment thread
alexander-alderman-webb marked this conversation as resolved.

# _after_cursor_execute does not get called for crashing SQL stmts. Judging
# from SQLAlchemy codebase it does seem like any error coming into this
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Expand Down Expand Up @@ -132,15 +137,20 @@ def _get_db_system(name: str) -> "Optional[str]":
return None


def _set_db_data(span: "Span", conn: "Any") -> None:
def _set_db_data(span: "Union[Span, StreamedSpan]", conn: "Any") -> None:
if isinstance(span, StreamedSpan):
set_on_span = span.set_attribute
else:
set_on_span = span.set_data

db_system = _get_db_system(conn.engine.name)
if db_system is not None:
span.set_data(SPANDATA.DB_SYSTEM, db_system)
set_on_span(SPANDATA.DB_SYSTEM, db_system)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated

try:
driver = conn.dialect.driver
if driver:
span.set_data(SPANDATA.DB_DRIVER_NAME, driver)
set_on_span(SPANDATA.DB_DRIVER_NAME, driver)
except Exception:
pass

Expand All @@ -149,12 +159,12 @@ def _set_db_data(span: "Span", conn: "Any") -> None:

db_name = conn.engine.url.database
if db_name is not None:
span.set_data(SPANDATA.DB_NAME, db_name)
set_on_span(SPANDATA.DB_NAME, db_name)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated

server_address = conn.engine.url.host
if server_address is not None:
span.set_data(SPANDATA.SERVER_ADDRESS, server_address)
set_on_span(SPANDATA.SERVER_ADDRESS, server_address)

server_port = conn.engine.url.port
if server_port is not None:
span.set_data(SPANDATA.SERVER_PORT, server_port)
set_on_span(SPANDATA.SERVER_PORT, server_port)
57 changes: 42 additions & 15 deletions sentry_sdk/tracing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,10 @@ def record_sql_queries(
executemany: bool,
record_cursor_repr: bool = False,
span_origin: str = "manual",
) -> "Generator[sentry_sdk.tracing.Span, None, None]":
) -> "Generator[Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan], None, None]":
# TODO: Bring back capturing of params by default
if sentry_sdk.get_client().options["_experiments"].get("record_sql_params", False):
client = sentry_sdk.get_client()
if client.options["_experiments"].get("record_sql_params", False):
if not params_list or params_list == [None]:
params_list = None

Expand All @@ -160,14 +161,26 @@ def record_sql_queries(
with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(message=query, category="query", data=data)

with sentry_sdk.start_span(
op=OP.DB,
name=query,
origin=span_origin,
) as span:
for k, v in data.items():
span.set_data(k, v)
yield span
if has_span_streaming_enabled(client.options):
with sentry_sdk.traces.start_span(
name="<unknown SQL query>" if query is None else query,
attributes={
"sentry.origin": span_origin,
"sentry.op": OP.DB,
},
) as span:
for k, v in data.items():
span.set_attribute(k, v)
yield span
Comment thread
alexander-alderman-webb marked this conversation as resolved.
else:
with sentry_sdk.start_span(
op=OP.DB,
name=query,
Comment thread
alexander-alderman-webb marked this conversation as resolved.
origin=span_origin,
) as span:
for k, v in data.items():
span.set_data(k, v)
yield span


def maybe_create_breadcrumbs_from_span(
Expand Down Expand Up @@ -313,22 +326,36 @@ def add_source(
span.set_attribute("code.function.name", frame.f_code.co_name)


def add_query_source(span: "sentry_sdk.tracing.Span") -> None:
def add_query_source(
span: "Union[sentry_sdk.tracing.Span, sentry_sdk.traces.StreamedSpan]",
) -> None:
"""
Adds OTel compatible source code information to a database query span
"""
client = sentry_sdk.get_client()
if not client.is_active():
return

if span.timestamp is None or span.start_timestamp is None:
if isinstance(span, LegacySpan):
if not client.is_active():
return
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated

# In the StreamedSpan case, we need to add the extra span information before
# the span finishes, so it's expected that this will be None. In the LegacySpan case,
# it should already be finished.
if span.timestamp is None:
return

if span.start_timestamp is None:
return

should_add_query_source = client.options.get("enable_db_query_source", True)
if not should_add_query_source:
return

duration = span.timestamp - span.start_timestamp
end_timestamp = (
datetime.now(timezone.utc) if span.timestamp is None else span.timestamp
)

duration = end_timestamp - span.start_timestamp
threshold = client.options.get("db_query_source_threshold_ms", 0)
slow_query = duration / timedelta(milliseconds=1) > threshold

Expand Down
28 changes: 20 additions & 8 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,24 +476,36 @@

@pytest.fixture
def render_span_tree():
def inner(event):
assert event["type"] == "transaction"
def inner(spans, root_span=None):
streamed_spans = False
if root_span is None:
streamed_spans = True

by_parent = {}
for span in event["spans"]:
for span in spans:
print(span)
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated
if "parent_span_id" not in span:
root_span = span
continue
Comment thread
alexander-alderman-webb marked this conversation as resolved.

Comment thread
alexander-alderman-webb marked this conversation as resolved.
by_parent.setdefault(span["parent_span_id"], []).append(span)

def render_span(span):
yield "- op={}: description={}".format(
json.dumps(span.get("op")), json.dumps(span.get("description"))
)
if streamed_spans:
yield "- sentry.op={}: name={}".format(
json.dumps(span["attributes"].get("sentry.op")),
json.dumps(span["name"]),
)
else:
yield "- op={}: description={}".format(
json.dumps(span.get("op")), json.dumps(span.get("description"))
)

for subspan in by_parent.get(span["span_id"]) or ():
for line in render_span(subspan):
yield " {}".format(line)

root_span = event["contexts"]["trace"]

return "\n".join(render_span(root_span))

Check warning on line 508 in tests/conftest.py

View check run for this annotation

@sentry/warden / warden: code-review

Potential runtime error if no root span found in streamed spans

When `root_span=None` (streamed spans mode), the code expects to find a span without `parent_span_id` in the spans list. If no such span exists (e.g., all spans have a `parent_span_id`), `root_span` remains `None` and `render_span(root_span)` at line 508 will attempt to access `span["attributes"]` on `None`, raising a `TypeError`. This is a potential edge case that could cause confusing test failures.

return inner

Expand Down
3 changes: 2 additions & 1 deletion tests/integrations/django/asgi/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,9 @@ async def test_async_middleware_spans(

(transaction,) = events

assert transaction["type"] == "transaction"
assert (
render_span_tree(transaction)
render_span_tree(transaction["spans"], transaction["contexts"]["trace"])
== """\
- op="http.server": description=null
- op="event.django": description="django.db.reset_queries"
Expand Down
Loading
Loading