Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
878dc5b
integration for aiomysql
tonal Aug 14, 2025
29b4e85
Drop _wrap_cursor_creation in integration aiomysql.py
tonal Aug 20, 2025
28e79b6
Potential KeyError in _wrap_connect
tonal Aug 20, 2025
950032f
Merge branch 'getsentry:master' into patch-2
tonal Aug 20, 2025
421abfa
Merge branch 'master' into patch-2
tonal Sep 10, 2025
ab35ef8
Merge branch 'master' into patch-2
antonpirker Sep 11, 2025
c09eb44
Merge branch 'master' into patch-2
tonal Sep 16, 2025
b3b04a1
Merge branch 'master' into patch-2
tonal Oct 10, 2025
d9d7282
Merge branch 'master' into patch-2
tonal Oct 31, 2025
faf1a87
Merge branch 'getsentry:master' into patch-2
tonal Apr 12, 2026
89b8669
feat(aiomysql): Add aiomysql integration
tonal Apr 12, 2026
ad11c32
fix(aiomysql): Address PR review comments
tonal Apr 13, 2026
e25d3ad
Merge remote-tracking branch 'origin/patch-2' into patch-2
tonal Apr 13, 2026
5f26753
fix(aiomysql): Remove duplicate MySQL service block from Jinja template
tonal Apr 13, 2026
a7319c9
fix(aiomysql): Use public cursor.connection instead of private _conne…
tonal Apr 13, 2026
5d39b16
fix(aiomysql): Use explicit signature in _wrap_connect to handle posi…
tonal Apr 13, 2026
facbe7b
Merge branch 'master' into patch-2
tonal Apr 13, 2026
3d85885
fix(aiomysql): Format code and add cryptography dep for MySQL 8.0 auth
tonal Apr 14, 2026
6e05b78
fix(aiomysql): Instrument Connection._connect to cover create_pool an…
tonal Apr 14, 2026
308571f
fix(aiomysql): Reuse _set_db_data in connect span for consistent null…
tonal Apr 14, 2026
a8e79d5
Merge upstream/master into patch-2
tonal May 12, 2026
93a0de6
Merge upstream/master into patch-2
tonal May 12, 2026
81825b6
fix(aiomysql): Use record_sql_queries_supporting_streaming
tonal May 12, 2026
a0143fd
fix(aiomysql): Support span streaming in _set_db_data and _wrap_connect
tonal May 12, 2026
53a276c
fix(aiomysql): Handle add_query_source for StreamedSpan and fix test …
tonal May 12, 2026
1a47a2e
fix(aiomysql): Add cryptography dep for MySQL 8.0 auth
tonal May 21, 2026
020d582
fix(aiomysql): Remove from auto-enabling list per review
tonal May 21, 2026
c09a123
Merge upstream/master into patch-2
tonal May 21, 2026
d15d55a
fix(aiomysql): Use record_sql_queries (renamed back in upstream)
tonal May 21, 2026
36ed7d6
fix(aiomysql): Fix mypy errors and remove unrelated files
tonal May 21, 2026
7a646df
fix(aiomysql): Use import-not-found only in type: ignore
tonal May 21, 2026
fe98f95
fix(aiomysql): Fix mypy import-not-found on single line
tonal May 21, 2026
61ce6aa
Merge remote-tracking branch 'upstream/master' into patch-2
tonal May 22, 2026
db77a50
Merge branch 'master' into patch-2
tonal May 22, 2026
ddd200d
fix(aiomysql): Move config entry to alphabetical order
tonal May 22, 2026
43ca59d
fix(aiomysql): Populate breadcrumb data independently of span
tonal May 22, 2026
a1b709b
Merge upstream/master into patch-2
tonal Jun 1, 2026
7c952eb
fix(ci): Add MySQL service container support to CI workflows
tonal Jun 1, 2026
da6db01
fix(aiomysql): Use correct span attributes for StreamedSpan
tonal Jun 1, 2026
f59e01c
fix(aiomysql): Separate breadcrumb data from span attributes
tonal Jun 1, 2026
e14f988
Merge upstream/master into patch-2
tonal Jun 2, 2026
dad32a4
Merge upstream/master into patch-2
tonal Jun 3, 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
2 changes: 0 additions & 2 deletions .github/workflows/test-integrations-web-1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,11 @@ jobs:
image: postgres
env:
POSTGRES_PASSWORD: sentry
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
# Maps tcp port 5432 on service container to the host
Comment thread
tonal marked this conversation as resolved.
ports:
- 5432:5432
env:
Expand Down
7 changes: 7 additions & 0 deletions scripts/populate_tox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@
},
"python": ">=3.7",
},
"aiomysql": {
"package": "aiomysql",
"deps": {
"*": ["pytest-asyncio", "cryptography"],
},
"python": ">=3.7",
},
"anthropic": {
"package": "anthropic",
"deps": {
Expand Down
6 changes: 6 additions & 0 deletions scripts/split_tox_gh_actions/split_tox_gh_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
"asyncpg",
}

FRAMEWORKS_NEEDING_MYSQL = {
"aiomysql",
}

FRAMEWORKS_NEEDING_REDIS = {
"celery",
}
Expand Down Expand Up @@ -99,6 +103,7 @@
"gcp",
],
"DBs": [
"aiomysql",
"asyncpg",
"clickhouse_driver",
"pymongo",
Expand Down Expand Up @@ -330,6 +335,7 @@ def render_template(group, frameworks, py_versions):
"frameworks": frameworks,
"needs_clickhouse": bool(set(frameworks) & FRAMEWORKS_NEEDING_CLICKHOUSE),
"needs_docker": bool(set(frameworks) & FRAMEWORKS_NEEDING_DOCKER),
"needs_mysql": bool(set(frameworks) & FRAMEWORKS_NEEDING_MYSQL),
Comment thread
cursor[bot] marked this conversation as resolved.
"needs_postgres": bool(set(frameworks) & FRAMEWORKS_NEEDING_POSTGRES),
"needs_redis": bool(set(frameworks) & FRAMEWORKS_NEEDING_REDIS),
"needs_java": bool(set(frameworks) & FRAMEWORKS_NEEDING_JAVA),
Expand Down
22 changes: 21 additions & 1 deletion scripts/split_tox_gh_actions/templates/test_group.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
python-version: [{{ py_versions|join(",") }}]
os: [ubuntu-22.04]

{% if needs_docker or needs_postgres or needs_redis %}
{% if needs_docker or needs_postgres or needs_redis or needs_mysql %}
services:
{% if needs_docker %}
docker:
Expand Down Expand Up @@ -41,6 +41,20 @@
ports:
- 6379:6379
{% endif %}
{% if needs_mysql %}
mysql:
image: mysql
env:
MYSQL_ROOT_PASSWORD: sentry
MYSQL_DATABASE: test_db
options: >-
--health-cmd "mysqladmin ping -h localhost"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 3306:3306
{% endif %}
{% endif %}
env:
# 3.6/3.7 run in the python:X.Y container; tell uv to use that system Python.
Expand All @@ -53,6 +67,12 @@
{% if needs_redis %}
SENTRY_PYTHON_TEST_REDIS_HOST: {% raw %}${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && 'redis' || 'localhost' }}{% endraw %}
{% endif %}
{% if needs_mysql %}
SENTRY_PYTHON_TEST_MYSQL_HOST: {% raw %}${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && 'mysql' || 'localhost' }}{% endraw %}
SENTRY_PYTHON_TEST_MYSQL_USER: root
SENTRY_PYTHON_TEST_MYSQL_PASSWORD: sentry
SENTRY_PYTHON_TEST_MYSQL_DB: test_db
{% endif %}
container: {% raw %}${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && format('python:{0}', matrix.python-version) || null }}{% endraw %}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
1 change: 1 addition & 0 deletions sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def iter_default_integrations(

_MIN_VERSIONS = {
"aiohttp": (3, 4),
"aiomysql": (0, 1, 1),
"anthropic": (0, 16),
"ariadne": (0, 20),
"arq": (0, 23),
Expand Down
275 changes: 275 additions & 0 deletions sentry_sdk/integrations/aiomysql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
# -*- coding: utf-8 -*-
from __future__ import annotations

from typing import Any, Awaitable, Callable, TypeVar

import sentry_sdk
from sentry_sdk.consts import OP, SPANDATA
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
from sentry_sdk.traces import StreamedSpan
from sentry_sdk.tracing_utils import (
add_query_source,
has_span_streaming_enabled,
record_sql_queries,
)
from sentry_sdk.utils import (
capture_internal_exceptions,
parse_version,
)

try:
import aiomysql # type: ignore[import-not-found]
from aiomysql.connection import Connection # type: ignore[import-not-found]
from aiomysql.cursors import Cursor # type: ignore[import-not-found]
except ImportError:
raise DidNotEnable("aiomysql not installed.")


class AioMySQLIntegration(Integration):
identifier = "aiomysql"
origin = f"auto.db.{identifier}"
_record_params = False

def __init__(self, *, record_params: bool = False):
AioMySQLIntegration._record_params = record_params

@staticmethod
def setup_once() -> None:
aiomysql_version = parse_version(aiomysql.__version__)
_check_minimum_version(AioMySQLIntegration, aiomysql_version)

Cursor.execute = _wrap_execute(Cursor.execute)
Cursor.executemany = _wrap_executemany(Cursor.executemany)

# Patch Connection._connect — this catches ALL connections:
# - aiomysql.connect()
# - aiomysql.create_pool() (pool.py does `from .connection import connect`
# which ultimately calls Connection._connect)
# - Reconnects
Connection._connect = _wrap_connect(Connection._connect)


T = TypeVar("T")


def _normalize_query(query: str | bytes | bytearray) -> str:
if isinstance(query, (bytes, bytearray)):
query = query.decode("utf-8", errors="replace")
return " ".join(query.split())


def _wrap_execute(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
"""Wrap Cursor.execute to capture SQL queries."""

async def _inner(*args: Any, **kwargs: Any) -> T:
if sentry_sdk.get_client().get_integration(AioMySQLIntegration) is None:
return await f(*args, **kwargs)

cursor = args[0]

# Skip if flagged by executemany (avoids double-recording).
# Do NOT reset the flag here — it must stay True for the entire
# duration of executemany, which may call execute multiple times
# in a loop (non-INSERT fallback). Only _wrap_executemany's
# finally block should clear it.
if getattr(cursor, "_sentry_skip_next_execute", False):
return await f(*args, **kwargs)
Comment thread
sentry[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

query = args[1] if len(args) > 1 else kwargs.get("query", "")
query_str = _normalize_query(query)
params = args[2] if len(args) > 2 else kwargs.get("args")

conn = _get_connection(cursor)

integration = sentry_sdk.get_client().get_integration(AioMySQLIntegration)
params_list = params if integration and integration._record_params else None
param_style = "pyformat" if params_list else None
Comment thread
sentry[bot] marked this conversation as resolved.

with record_sql_queries(
cursor=None,
query=query_str,
params_list=params_list,
paramstyle=param_style,
executemany=False,
span_origin=AioMySQLIntegration.origin,
) as span:
if conn:
_set_db_data(span, conn)
res = await f(*args, **kwargs)
if isinstance(span, StreamedSpan):
with capture_internal_exceptions():
add_query_source(span)

if not isinstance(span, StreamedSpan):
with capture_internal_exceptions():
add_query_source(span)

return res

return _inner


def _wrap_executemany(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
"""Wrap Cursor.executemany to capture SQL queries."""

async def _inner(*args: Any, **kwargs: Any) -> T:
if sentry_sdk.get_client().get_integration(AioMySQLIntegration) is None:
return await f(*args, **kwargs)

cursor = args[0]
query = args[1] if len(args) > 1 else kwargs.get("query", "")
query_str = _normalize_query(query)
seq_of_params = args[2] if len(args) > 2 else kwargs.get("args")

conn = _get_connection(cursor)

integration = sentry_sdk.get_client().get_integration(AioMySQLIntegration)
params_list = (
seq_of_params if integration and integration._record_params else None
)
param_style = "pyformat" if params_list else None

# Prevent double-recording: _do_execute_many calls self.execute internally
cursor._sentry_skip_next_execute = True
try:
with record_sql_queries(
cursor=None,
query=query_str,
params_list=params_list,
paramstyle=param_style,
executemany=True,
span_origin=AioMySQLIntegration.origin,
) as span:
if conn:
_set_db_data(span, conn)
res = await f(*args, **kwargs)
if isinstance(span, StreamedSpan):
with capture_internal_exceptions():
add_query_source(span)

if not isinstance(span, StreamedSpan):
with capture_internal_exceptions():
add_query_source(span)

return res
finally:
cursor._sentry_skip_next_execute = False
Comment thread
tonal marked this conversation as resolved.

return _inner


def _get_connection(cursor: Any) -> Any:
"""Get the underlying connection from a cursor."""
return getattr(cursor, "connection", None)


def _wrap_connect(f: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
"""Wrap Connection._connect to capture connection spans."""

async def _inner(self: "Connection") -> T:
client = sentry_sdk.get_client()
if client.get_integration(AioMySQLIntegration) is None:
return await f(self)

if has_span_streaming_enabled(client.options):
breadcrumb_data = _get_connect_data(self, use_streaming_keys=True)

span_attributes: dict[str, Any] = {
"sentry.op": OP.DB,
"sentry.origin": AioMySQLIntegration.origin,
} | breadcrumb_data

with sentry_sdk.traces.start_span(
name="connect", attributes=span_attributes
) as span:
with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(
message="connect", category="query", data=breadcrumb_data
)
Comment thread
sentry[bot] marked this conversation as resolved.
res = await f(self)
else:
connect_data = _get_connect_data(self)

with sentry_sdk.start_span(
op=OP.DB,
name="connect",
origin=AioMySQLIntegration.origin,
) as span:
_set_db_data(span, self)

with capture_internal_exceptions():
sentry_sdk.add_breadcrumb(
message="connect",
category="query",
data=connect_data,
)
res = await f(self)

return res

return _inner


def _get_connect_data(conn: Any, *, use_streaming_keys: bool = False) -> dict[str, Any]:
if use_streaming_keys:
db_system = SPANDATA.DB_SYSTEM_NAME
db_name = SPANDATA.DB_NAMESPACE
else:
db_system = SPANDATA.DB_SYSTEM
db_name = SPANDATA.DB_NAME

data: dict[str, Any] = {
db_system: "mysql",
SPANDATA.DB_DRIVER_NAME: "aiomysql",
}

host = getattr(conn, "host", None)
if host is not None:
data[SPANDATA.SERVER_ADDRESS] = host

port = getattr(conn, "port", None)
if port is not None:
data[SPANDATA.SERVER_PORT] = port

database = getattr(conn, "db", None)
if database is not None:
data[db_name] = database

user = getattr(conn, "user", None)
if user is not None:
data[SPANDATA.DB_USER] = user

return data


def _set_db_data(span: Any, conn: Any) -> None:
"""Set database-related span data from connection object."""
if isinstance(span, StreamedSpan):
set_value = span.set_attribute
db_system = SPANDATA.DB_SYSTEM_NAME
db_name = SPANDATA.DB_NAMESPACE
else:
# Remove this else block once we've completely migrated to streamed spans
# The use of deprecated attributes here is to ensure backwards compatibility
set_value = span.set_data
db_system = SPANDATA.DB_SYSTEM
db_name = SPANDATA.DB_NAME

set_value(db_system, "mysql")
set_value(SPANDATA.DB_DRIVER_NAME, "aiomysql")

host = getattr(conn, "host", None)
if host is not None:
set_value(SPANDATA.SERVER_ADDRESS, host)

port = getattr(conn, "port", None)
if port is not None:
set_value(SPANDATA.SERVER_PORT, port)

database = getattr(conn, "db", None)
if database is not None:
set_value(db_name, database)

user = getattr(conn, "user", None)
if user is not None:
set_value(SPANDATA.DB_USER, user)
4 changes: 4 additions & 0 deletions tests/integrations/aiomysql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import pytest

pytest.importorskip("aiomysql")
pytest.importorskip("pytest_asyncio")
Loading
Loading