diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index b8d83cff98..3e1bc4c4e4 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -44,6 +44,19 @@ jobs: --health-retries 5 ports: - 3306:3306 + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.1.4 + env: + discovery.type: single-node + xpack.security.enabled: false + ES_JAVA_OPTS: -Xms512m -Xmx512m + options: >- + --health-cmd "curl -f http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 env: # 3.6/3.7 run in the python:X.Y container; tell uv to use that system Python. TOX_UV_PYTHON_PREFERENCE: ${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && 'only-system' || 'managed' }} @@ -52,6 +65,7 @@ jobs: SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry SENTRY_PYTHON_TEST_MYSQL_USER: root SENTRY_PYTHON_TEST_MYSQL_PASSWORD: sentry + SENTRY_PYTHON_TEST_ES_HOST: ${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && 'elasticsearch' || 'localhost' }} container: ${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && format('python:{0}', matrix.python-version) || null }} steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 @@ -81,6 +95,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-clickhouse_driver" + - name: Test elasticsearch + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-elasticsearch" - name: Test pymongo run: | set -x # print commands that are executed diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index a2640e14d5..85e9bc8b2e 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -128,6 +128,13 @@ "package": "dramatiq", "num_versions": 2, }, + "elasticsearch": { + "package": "elasticsearch", + "deps": { + "*": ["pytest-asyncio", "aiohttp"], + }, + "include": ">=8", + }, "falcon": { "package": "falcon", "python": "<3.13", diff --git a/scripts/split_tox_gh_actions/split_tox_gh_actions.py b/scripts/split_tox_gh_actions/split_tox_gh_actions.py index d6b5494515..b3c3b2295c 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -58,6 +58,10 @@ "clickhouse_driver", } +FRAMEWORKS_NEEDING_ES = { + "elasticsearch", +} + FRAMEWORKS_NEEDING_DOCKER = { "aws_lambda", } @@ -106,6 +110,7 @@ "aiomysql", "asyncpg", "clickhouse_driver", + "elasticsearch", "pymongo", "redis", "redis_py_cluster_legacy", @@ -334,6 +339,7 @@ def render_template(group, frameworks, py_versions): "group": group, "frameworks": frameworks, "needs_clickhouse": bool(set(frameworks) & FRAMEWORKS_NEEDING_CLICKHOUSE), + "needs_es": bool(set(frameworks) & FRAMEWORKS_NEEDING_ES), "needs_docker": bool(set(frameworks) & FRAMEWORKS_NEEDING_DOCKER), "needs_mysql": bool(set(frameworks) & FRAMEWORKS_NEEDING_MYSQL), "needs_postgres": bool(set(frameworks) & FRAMEWORKS_NEEDING_POSTGRES), diff --git a/scripts/split_tox_gh_actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja index 0e1409c1bd..0b8730506a 100644 --- a/scripts/split_tox_gh_actions/templates/test_group.jinja +++ b/scripts/split_tox_gh_actions/templates/test_group.jinja @@ -8,7 +8,7 @@ python-version: [{{ py_versions|join(",") }}] os: [ubuntu-22.04] - {% if needs_docker or needs_postgres or needs_redis or needs_mysql %} + {% if needs_docker or needs_postgres or needs_redis or needs_mysql or needs_es %} services: {% if needs_docker %} docker: @@ -54,6 +54,21 @@ ports: - 3306:3306 {% endif %} + {% if needs_es %} + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:9.1.4 + env: + discovery.type: single-node + xpack.security.enabled: false + ES_JAVA_OPTS: -Xms512m -Xmx512m + options: >- + --health-cmd "curl -f http://localhost:9200/_cluster/health" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + ports: + - 9200:9200 + {% endif %} {% endif %} env: # 3.6/3.7 run in the python:X.Y container; tell uv to use that system Python. @@ -70,6 +85,9 @@ SENTRY_PYTHON_TEST_MYSQL_USER: root SENTRY_PYTHON_TEST_MYSQL_PASSWORD: sentry {% endif %} + {% if needs_es %} + SENTRY_PYTHON_TEST_ES_HOST: {% raw %}${{ (matrix.python-version == '3.6' || matrix.python-version == '3.7') && 'elasticsearch' || 'localhost' }}{% endraw %} + {% 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 677d34a81e..d70ae1c175 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -77,6 +77,7 @@ def iter_default_integrations( "sentry_sdk.integrations.clickhouse_driver.ClickhouseDriverIntegration", "sentry_sdk.integrations.cohere.CohereIntegration", "sentry_sdk.integrations.django.DjangoIntegration", + "sentry_sdk.integrations.elasticsearch.ElasticsearchIntegration", "sentry_sdk.integrations.falcon.FalconIntegration", "sentry_sdk.integrations.fastapi.FastApiIntegration", "sentry_sdk.integrations.flask.FlaskIntegration", @@ -132,6 +133,7 @@ def iter_default_integrations( "cohere": (5, 4, 0), "django": (1, 8), "dramatiq": (1, 9), + "elasticsearch": (8, 0), "falcon": (1, 4), "fastapi": (0, 79, 0), "flask": (1, 1, 4), diff --git a/sentry_sdk/integrations/elasticsearch.py b/sentry_sdk/integrations/elasticsearch.py new file mode 100644 index 0000000000..cf68e33739 --- /dev/null +++ b/sentry_sdk/integrations/elasticsearch.py @@ -0,0 +1,199 @@ +import functools +import json +from contextlib import contextmanager +from typing import TYPE_CHECKING, TypeVar + +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.traces import StreamedSpan +from sentry_sdk.tracing_utils import has_span_streaming_enabled +from sentry_sdk.utils import capture_internal_exceptions + +# Hack to get new Python features working in older versions +# without introducing a hard dependency on `typing_extensions` +# from: https://stackoverflow.com/a/71944042/300572 +if TYPE_CHECKING: + from collections.abc import Iterator + from typing import Any, Awaitable, Callable, Mapping, Optional, ParamSpec, Union + + from sentry_sdk.tracing import Span +else: + # Fake ParamSpec + class ParamSpec: + def __init__(self, _): + self.args = None + self.kwargs = None + + # Callable[anything] will return None + class _Callable: + def __getitem__(self, _): + return None + + # Make instances + Callable = _Callable() + +try: + from elasticsearch import ( # type: ignore[import-not-found] + __version__ as ES_VERSION, + ) + from elasticsearch._sync.client._base import ( # type: ignore[import-not-found] + BaseClient, + ) +except ImportError: + raise DidNotEnable("elasticsearch not installed.") + +try: + from elasticsearch._async.client._base import ( # type: ignore[import-not-found] + BaseClient as AsyncBaseClient, + ) +except ImportError: + # The async client's transport has optional dependencies. Missing them + # should not disable instrumentation of the sync client. + AsyncBaseClient = None + + +class ElasticsearchIntegration(Integration): + identifier = "elasticsearch" + origin = f"auto.db.{identifier}" + + @staticmethod + def setup_once() -> None: + _check_minimum_version(ElasticsearchIntegration, ES_VERSION) + + # Every API method of both the sync and the async client funnels its + # HTTP call through its class's `perform_request`, so patching the two + # of them covers the whole API surface, including raw + # `client.perform_request()` calls. + BaseClient.perform_request = _wrap_perform_request(BaseClient.perform_request) + if AsyncBaseClient is not None: + AsyncBaseClient.perform_request = _wrap_perform_request_async( + AsyncBaseClient.perform_request + ) + + +P = ParamSpec("P") +T = TypeVar("T") + + +def _wrap_perform_request(f: "Callable[P, T]") -> "Callable[P, T]": + @functools.wraps(f) + def _inner(*args: "P.args", **kwargs: "P.kwargs") -> "T": + client = sentry_sdk.get_client() + if client.get_integration(ElasticsearchIntegration) is None: + return f(*args, **kwargs) + + with _es_span(client, args, kwargs): + return f(*args, **kwargs) + + return _inner + + +def _wrap_perform_request_async( + f: "Callable[P, Awaitable[T]]", +) -> "Callable[P, Awaitable[T]]": + @functools.wraps(f) + async def _inner(*args: "P.args", **kwargs: "P.kwargs") -> "T": + client = sentry_sdk.get_client() + if client.get_integration(ElasticsearchIntegration) is None: + return await f(*args, **kwargs) + + with _es_span(client, args, kwargs): + return await f(*args, **kwargs) + + return _inner + + +@contextmanager +def _es_span( + client: "sentry_sdk.client.BaseClient", args: "Any", kwargs: "Any" +) -> "Iterator[None]": + instance = args[0] + method = args[1] if len(args) > 1 else kwargs.get("method") + path = args[2] if len(args) > 2 else kwargs.get("path") + # `endpoint_id` (e.g. "search", "indices.refresh") and `path_parts` (e.g. + # {"index": "my-index"}) are only passed by elasticsearch >= 8.12. + endpoint_id = kwargs.get("endpoint_id") + path_parts = kwargs.get("path_parts") + + name = str(endpoint_id) if endpoint_id else "{} {}".format(method, path) + + if has_span_streaming_enabled(client.options): + span = sentry_sdk.traces.start_span( + name=name, + attributes={ + "sentry.op": OP.DB, + "sentry.origin": ElasticsearchIntegration.origin, + SPANDATA.DB_SYSTEM_NAME: "elasticsearch", + }, + ) + _set_db_data(span, instance, method, path, endpoint_id, path_parts, kwargs) + try: + yield + finally: + span.end() + else: + with sentry_sdk.start_span( + op=OP.DB, + name=name, + origin=ElasticsearchIntegration.origin, + ) as span: + span.set_data(SPANDATA.DB_SYSTEM, "elasticsearch") + _set_db_data(span, instance, method, path, endpoint_id, path_parts, kwargs) + + try: + yield + finally: + with capture_internal_exceptions(): + if span.scope is not None: + span.scope.add_breadcrumb( + message=name, category="query", data=dict(span._data) + ) + + +def _set_db_data( + span: "Union[Span, StreamedSpan]", + instance: "Any", + method: "Optional[str]", + path: "Optional[str]", + endpoint_id: "Optional[str]", + path_parts: "Optional[Mapping[str, Any]]", + kwargs: "Any", +) -> None: + if isinstance(span, StreamedSpan): + set_on_span = span.set_attribute + db_operation_key = SPANDATA.DB_OPERATION_NAME + else: + set_on_span = span.set_data + db_operation_key = SPANDATA.DB_OPERATION + + if endpoint_id: + set_on_span(db_operation_key, str(endpoint_id)) + + if method: + set_on_span("http.request.method", str(method)) + + if path: + set_on_span("url.path", str(path)) + + if path_parts: + with capture_internal_exceptions(): + for key, value in path_parts.items(): + set_on_span("db.elasticsearch.path_parts.{}".format(key), str(value)) + + with capture_internal_exceptions(): + # The client only knows which node served a request at the transport + # layer, so only report the server when there is no ambiguity. + nodes = list(instance.transport.node_pool.all()) + if len(nodes) == 1: + set_on_span(SPANDATA.SERVER_ADDRESS, nodes[0].config.host) + set_on_span(SPANDATA.SERVER_PORT, nodes[0].config.port) + + body = kwargs.get("body") + if body is not None and should_send_default_pii(): + if isinstance(span, StreamedSpan): + with capture_internal_exceptions(): + set_on_span(SPANDATA.DB_QUERY_TEXT, json.dumps(body, default=str)) + else: + set_on_span(SPANDATA.DB_QUERY_TEXT, body) diff --git a/tests/integrations/elasticsearch/__init__.py b/tests/integrations/elasticsearch/__init__.py new file mode 100644 index 0000000000..666e4a636e --- /dev/null +++ b/tests/integrations/elasticsearch/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("elasticsearch") diff --git a/tests/integrations/elasticsearch/test_elasticsearch.py b/tests/integrations/elasticsearch/test_elasticsearch.py new file mode 100644 index 0000000000..24f5a03d67 --- /dev/null +++ b/tests/integrations/elasticsearch/test_elasticsearch.py @@ -0,0 +1,313 @@ +""" +Tests need a local Elasticsearch instance running, this can best be done using +```sh +docker run -d -p 9200:9200 --name elasticsearch-test --rm \ + -e discovery.type=single-node -e xpack.security.enabled=false \ + docker.elastic.co/elasticsearch/elasticsearch:9.1.4 +``` +""" + +import json +import os +import uuid + +import elasticsearch +import pytest +from elasticsearch import Elasticsearch + +from sentry_sdk import capture_message, start_transaction +from sentry_sdk.consts import SPANDATA +from sentry_sdk.integrations.elasticsearch import ElasticsearchIntegration +from tests.conftest import ApproxDict + +ES_HOST = os.environ.get("SENTRY_PYTHON_TEST_ES_HOST", "localhost") +ES_URL = "http://{}:9200".format(ES_HOST) + +# The client only passes `endpoint_id` and `path_parts` to `perform_request` +# since 8.12. On older versions spans are named after the HTTP request instead. +HAS_ENDPOINT_METADATA = elasticsearch.__version__ >= (8, 12, 0) + + +def expected_span_names(index): + if HAS_ENDPOINT_METADATA: + return ["index", "indices.refresh", "search"] + + return [ + "PUT /{}/_doc/1".format(index), + "POST /{}/_refresh".format(index), + "POST /{}/_search".format(index), + ] + + +@pytest.fixture +def index(): + client = Elasticsearch(ES_URL) + index = "sentry-test-{}".format(uuid.uuid4().hex[:8]) + client.indices.create(index=index) + yield index + client.options(ignore_status=404).indices.delete(index=index) + + +def test_breadcrumbs(sentry_init, capture_events, index) -> None: + sentry_init(integrations=[ElasticsearchIntegration()]) + events = capture_events() + + client = Elasticsearch(ES_URL) + client.index(index=index, id="1", document={"title": "hello"}) + client.indices.refresh(index=index) + res = client.search(index=index, query={"match": {"title": "hello"}}) + assert res["hits"]["total"]["value"] == 1 + + capture_message("hi") + + (event,) = events + crumbs = [ + crumb + for crumb in event["breadcrumbs"]["values"] + if crumb["category"] == "query" + ] + + assert [crumb["message"] for crumb in crumbs] == expected_span_names(index) + for crumb in crumbs: + assert crumb["data"] == ApproxDict( + { + SPANDATA.DB_SYSTEM: "elasticsearch", + SPANDATA.SERVER_ADDRESS: ES_HOST, + SPANDATA.SERVER_PORT: 9200, + } + ) + + if HAS_ENDPOINT_METADATA: + assert crumbs[2]["data"][SPANDATA.DB_OPERATION] == "search" + assert crumbs[2]["data"]["db.elasticsearch.path_parts.index"] == index + + +def test_transaction_spans(sentry_init, capture_events, index) -> None: + sentry_init(integrations=[ElasticsearchIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = Elasticsearch(ES_URL) + + with start_transaction(name="test_transaction"): + client.index(index=index, id="1", document={"title": "hello"}) + client.indices.refresh(index=index) + client.search(index=index, query={"match": {"title": "hello"}}) + + (event,) = events + spans = [span for span in event["spans"] if span["op"] == "db"] + + assert [span["description"] for span in spans] == expected_span_names(index) + for span in spans: + assert span["op"] == "db" + assert span["origin"] == "auto.db.elasticsearch" + assert span["data"][SPANDATA.DB_SYSTEM] == "elasticsearch" + assert span["data"][SPANDATA.SERVER_ADDRESS] == ES_HOST + assert span["data"][SPANDATA.SERVER_PORT] == 9200 + + search_span = spans[2] + assert search_span["data"]["url.path"] == "/{}/_search".format(index) + assert search_span["data"]["http.request.method"] == "POST" + if HAS_ENDPOINT_METADATA: + assert search_span["data"][SPANDATA.DB_OPERATION] == "search" + assert search_span["data"]["db.elasticsearch.path_parts.index"] == index + + +def test_no_query_body_without_pii(sentry_init, capture_events, index) -> None: + sentry_init(integrations=[ElasticsearchIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = Elasticsearch(ES_URL) + + with start_transaction(name="test_transaction"): + client.search(index=index, query={"match": {"title": "hello"}}) + + (event,) = events + (span,) = [span for span in event["spans"] if span["op"] == "db"] + + assert SPANDATA.DB_QUERY_TEXT not in span["data"] + + +def test_query_body_with_pii(sentry_init, capture_events, index) -> None: + sentry_init( + integrations=[ElasticsearchIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + client = Elasticsearch(ES_URL) + + with start_transaction(name="test_transaction"): + client.search(index=index, query={"match": {"title": "hello"}}) + + (event,) = events + (span,) = [span for span in event["spans"] if span["op"] == "db"] + + assert span["data"][SPANDATA.DB_QUERY_TEXT] == { + "query": {"match": {"title": "hello"}} + } + + +def test_errors_do_not_go_unnoticed(sentry_init, capture_events, index) -> None: + from elasticsearch import NotFoundError + + sentry_init(integrations=[ElasticsearchIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = Elasticsearch(ES_URL) + + with start_transaction(name="test_transaction"): + with pytest.raises(NotFoundError): + client.get(index="does-not-exist-{}".format(index), id="1") + + (event,) = events + (span,) = [span for span in event["spans"] if span["op"] == "db"] + + if HAS_ENDPOINT_METADATA: + assert span["description"] == "get" + else: + assert span["description"] == "GET /does-not-exist-{}/_doc/1".format(index) + assert span["status"] == "internal_error" + + +def test_client_untouched_when_integration_disabled(sentry_init, index) -> None: + sentry_init(integrations=[], auto_enabling_integrations=False) + + client = Elasticsearch(ES_URL) + client.index(index=index, id="1", document={"title": "hello"}) + client.indices.refresh(index=index) + + res = client.search(index=index, query={"match": {"title": "hello"}}) + assert res["hits"]["total"]["value"] == 1 + + +def test_span_origin(sentry_init, capture_events, index) -> None: + sentry_init(integrations=[ElasticsearchIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = Elasticsearch(ES_URL) + + with start_transaction(name="test_transaction"): + client.indices.refresh(index=index) + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + for span in event["spans"]: + if span["op"] == "db": + assert span["origin"] == "auto.db.elasticsearch" + + +@pytest.mark.asyncio +async def test_async_breadcrumbs(sentry_init, capture_events, index) -> None: + AsyncElasticsearch = pytest.importorskip("elasticsearch").AsyncElasticsearch + pytest.importorskip("aiohttp") + + sentry_init(integrations=[ElasticsearchIntegration()]) + events = capture_events() + + client = AsyncElasticsearch(ES_URL) + try: + await client.index(index=index, id="1", document={"title": "hello"}) + await client.indices.refresh(index=index) + res = await client.search(index=index, query={"match": {"title": "hello"}}) + assert res["hits"]["total"]["value"] == 1 + finally: + await client.close() + + capture_message("hi") + + (event,) = events + crumbs = [ + crumb + for crumb in event["breadcrumbs"]["values"] + if crumb["category"] == "query" + ] + + assert [crumb["message"] for crumb in crumbs] == expected_span_names(index) + for crumb in crumbs: + assert crumb["data"] == ApproxDict( + { + SPANDATA.DB_SYSTEM: "elasticsearch", + SPANDATA.SERVER_ADDRESS: ES_HOST, + SPANDATA.SERVER_PORT: 9200, + } + ) + + +@pytest.mark.asyncio +async def test_async_transaction_spans(sentry_init, capture_events, index) -> None: + AsyncElasticsearch = pytest.importorskip("elasticsearch").AsyncElasticsearch + pytest.importorskip("aiohttp") + + sentry_init(integrations=[ElasticsearchIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = AsyncElasticsearch(ES_URL) + try: + with start_transaction(name="test_transaction"): + await client.search(index=index, query={"match": {"title": "hello"}}) + finally: + await client.close() + + (event,) = events + (span,) = [span for span in event["spans"] if span["op"] == "db"] + + if HAS_ENDPOINT_METADATA: + assert span["description"] == "search" + assert span["data"][SPANDATA.DB_OPERATION] == "search" + else: + assert span["description"] == "POST /{}/_search".format(index) + assert span["origin"] == "auto.db.elasticsearch" + assert span["data"][SPANDATA.DB_SYSTEM] == "elasticsearch" + + +@pytest.mark.parametrize("with_pii", [True, False]) +def test_span_streaming(sentry_init, capture_items, index, with_pii) -> None: + import sentry_sdk + + sentry_init( + integrations=[ElasticsearchIntegration()], + _experiments={"trace_lifecycle": "stream"}, + traces_sample_rate=1.0, + send_default_pii=with_pii, + ) + items = capture_items("span") + + with sentry_sdk.traces.start_span(name="custom parent") as parent: + trace_id = parent.trace_id + span_id = parent.span_id + + client = Elasticsearch(ES_URL) + client.search(index=index, query={"match": {"title": "hello"}}) + + sentry_sdk.flush() + + (span,) = [ + item.payload + for item in items + if item.payload["attributes"].get(SPANDATA.DB_SYSTEM_NAME) == "elasticsearch" + ] + + if HAS_ENDPOINT_METADATA: + assert span["name"] == "search" + assert span["attributes"][SPANDATA.DB_OPERATION_NAME] == "search" + assert span["attributes"]["db.elasticsearch.path_parts.index"] == index + else: + assert span["name"] == "POST /{}/_search".format(index) + + assert span["trace_id"] == trace_id + assert span["parent_span_id"] == span_id + assert span["attributes"]["sentry.op"] == "db" + assert span["attributes"]["sentry.origin"] == "auto.db.elasticsearch" + assert span["attributes"]["url.path"] == "/{}/_search".format(index) + assert span["attributes"]["http.request.method"] == "POST" + assert span["attributes"][SPANDATA.SERVER_ADDRESS] == ES_HOST + assert span["attributes"][SPANDATA.SERVER_PORT] == 9200 + + if with_pii: + assert json.loads(span["attributes"][SPANDATA.DB_QUERY_TEXT]) == { + "query": {"match": {"title": "hello"}} + } + else: + assert SPANDATA.DB_QUERY_TEXT not in span["attributes"] diff --git a/tox.ini b/tox.ini index 889af6d6bf..ba6ae2eaaa 100644 --- a/tox.ini +++ b/tox.ini @@ -164,6 +164,11 @@ envlist = {py3.9,py3.13,py3.14}-clickhouse_driver-v0.2.10 {py3.9,py3.13,py3.14}-clickhouse_driver-latest + {py3.6,py3.9,py3.10}-elasticsearch-v8.0.1 + {py3.8,py3.12,py3.13}-elasticsearch-v8.19.3 + {py3.10,py3.13,py3.14,py3.14t}-elasticsearch-v9.4.1 + {py3.10,py3.13,py3.14,py3.14t}-elasticsearch-latest + {py3.6}-pymongo-v3.5.1 {py3.6,py3.10,py3.11}-pymongo-v3.13.0 {py3.9,py3.13,py3.14,py3.14t}-pymongo-v4.17.0 @@ -10845,6 +10850,333 @@ deps = py3.14-clickhouse_driver-latest: tzlocal==5.4.4 + elasticsearch-v8.0.1: elasticsearch==8.0.1 + + py3.9-elasticsearch-v8.0.1: elastic-transport==8.19.0 + py3.9-elasticsearch-v8.0.1: urllib3==2.6.3 + py3.9-elasticsearch-v8.0.1: pytest-asyncio==1.2.0 + py3.9-elasticsearch-v8.0.1: backports.asyncio.runner==1.2.0 + py3.9-elasticsearch-v8.0.1: pytest==8.4.2 + py3.9-elasticsearch-v8.0.1: pluggy==1.6.0 + py3.9-elasticsearch-v8.0.1: aiohttp==3.13.5 + py3.9-elasticsearch-v8.0.1: async-timeout==5.0.1 + py3.9-elasticsearch-v8.0.1: multidict==6.7.1 + py3.9-elasticsearch-v8.0.1: yarl==1.22.0 + py3.9-elasticsearch-v8.0.1: aiohappyeyeballs==2.6.1 + py3.9-elasticsearch-v8.0.1: aiosignal==1.4.0 + py3.9-elasticsearch-v8.0.1: attrs==26.1.0 + py3.9-elasticsearch-v8.0.1: exceptiongroup==1.3.1 + py3.9-elasticsearch-v8.0.1: frozenlist==1.8.0 + py3.9-elasticsearch-v8.0.1: idna==3.18 + py3.9-elasticsearch-v8.0.1: iniconfig==2.1.0 + py3.9-elasticsearch-v8.0.1: packaging==26.2 + py3.9-elasticsearch-v8.0.1: propcache==0.4.1 + py3.9-elasticsearch-v8.0.1: Pygments==2.20.0 + py3.9-elasticsearch-v8.0.1: tomli==2.4.1 + py3.9-elasticsearch-v8.0.1: typing_extensions==4.16.0 + py3.9-elasticsearch-v8.0.1: certifi==2026.6.17 + + py3.10-elasticsearch-v8.0.1: pytest-asyncio==1.4.0 + py3.10-elasticsearch-v8.0.1: aiohttp==3.14.1 + py3.10-elasticsearch-v8.0.1: aiohappyeyeballs==2.7.1 + py3.10-elasticsearch-v8.0.1: aiosignal==1.4.0 + py3.10-elasticsearch-v8.0.1: async-timeout==5.0.1 + py3.10-elasticsearch-v8.0.1: attrs==26.1.0 + py3.10-elasticsearch-v8.0.1: backports.asyncio.runner==1.2.0 + py3.10-elasticsearch-v8.0.1: elastic-transport==8.19.0 + py3.10-elasticsearch-v8.0.1: frozenlist==1.8.0 + py3.10-elasticsearch-v8.0.1: multidict==6.7.1 + py3.10-elasticsearch-v8.0.1: propcache==0.5.2 + py3.10-elasticsearch-v8.0.1: pytest==9.1.1 + py3.10-elasticsearch-v8.0.1: typing_extensions==4.16.0 + py3.10-elasticsearch-v8.0.1: yarl==1.24.2 + py3.10-elasticsearch-v8.0.1: exceptiongroup==1.3.1 + py3.10-elasticsearch-v8.0.1: idna==3.18 + py3.10-elasticsearch-v8.0.1: iniconfig==2.3.0 + py3.10-elasticsearch-v8.0.1: packaging==26.2 + py3.10-elasticsearch-v8.0.1: pluggy==1.6.0 + py3.10-elasticsearch-v8.0.1: Pygments==2.20.0 + py3.10-elasticsearch-v8.0.1: tomli==2.4.1 + py3.10-elasticsearch-v8.0.1: urllib3==2.7.0 + py3.10-elasticsearch-v8.0.1: certifi==2026.6.17 + + elasticsearch-v8.19.3: elasticsearch==8.19.3 + + py3.8-elasticsearch-v8.19.3: pytest-asyncio==0.24.0 + py3.8-elasticsearch-v8.19.3: aiohttp==3.10.11 + py3.8-elasticsearch-v8.19.3: aiohappyeyeballs==2.4.4 + py3.8-elasticsearch-v8.19.3: aiosignal==1.3.1 + py3.8-elasticsearch-v8.19.3: async-timeout==5.0.1 + py3.8-elasticsearch-v8.19.3: attrs==25.3.0 + py3.8-elasticsearch-v8.19.3: elastic-transport==8.19.0 + py3.8-elasticsearch-v8.19.3: frozenlist==1.5.0 + py3.8-elasticsearch-v8.19.3: multidict==6.1.0 + py3.8-elasticsearch-v8.19.3: pytest==8.3.5 + py3.8-elasticsearch-v8.19.3: typing_extensions==4.13.2 + py3.8-elasticsearch-v8.19.3: yarl==1.15.2 + py3.8-elasticsearch-v8.19.3: python-dateutil==2.9.0.post0 + py3.8-elasticsearch-v8.19.3: exceptiongroup==1.3.1 + py3.8-elasticsearch-v8.19.3: idna==3.15 + py3.8-elasticsearch-v8.19.3: pluggy==1.5.0 + py3.8-elasticsearch-v8.19.3: propcache==0.2.0 + py3.8-elasticsearch-v8.19.3: six==1.17.0 + py3.8-elasticsearch-v8.19.3: tomli==2.4.1 + py3.8-elasticsearch-v8.19.3: urllib3==2.2.3 + py3.8-elasticsearch-v8.19.3: certifi==2026.6.17 + py3.8-elasticsearch-v8.19.3: iniconfig==2.1.0 + py3.8-elasticsearch-v8.19.3: packaging==26.2 + + py3.12-elasticsearch-v8.19.3: pytest-asyncio==1.4.0 + py3.12-elasticsearch-v8.19.3: aiohttp==3.14.1 + py3.12-elasticsearch-v8.19.3: aiohappyeyeballs==2.7.1 + py3.12-elasticsearch-v8.19.3: aiosignal==1.4.0 + py3.12-elasticsearch-v8.19.3: attrs==26.1.0 + py3.12-elasticsearch-v8.19.3: elastic-transport==8.19.0 + py3.12-elasticsearch-v8.19.3: frozenlist==1.8.0 + py3.12-elasticsearch-v8.19.3: multidict==6.7.1 + py3.12-elasticsearch-v8.19.3: propcache==0.5.2 + py3.12-elasticsearch-v8.19.3: pytest==9.1.1 + py3.12-elasticsearch-v8.19.3: typing_extensions==4.16.0 + py3.12-elasticsearch-v8.19.3: yarl==1.24.2 + py3.12-elasticsearch-v8.19.3: python-dateutil==2.9.0.post0 + py3.12-elasticsearch-v8.19.3: idna==3.18 + py3.12-elasticsearch-v8.19.3: iniconfig==2.3.0 + py3.12-elasticsearch-v8.19.3: packaging==26.2 + py3.12-elasticsearch-v8.19.3: pluggy==1.6.0 + py3.12-elasticsearch-v8.19.3: Pygments==2.20.0 + py3.12-elasticsearch-v8.19.3: six==1.17.0 + py3.12-elasticsearch-v8.19.3: urllib3==2.7.0 + py3.12-elasticsearch-v8.19.3: certifi==2026.6.17 + + py3.13-elasticsearch-v8.19.3: elastic-transport==8.19.0 + py3.13-elasticsearch-v8.19.3: urllib3==2.7.0 + py3.13-elasticsearch-v8.19.3: pytest-asyncio==1.4.0 + py3.13-elasticsearch-v8.19.3: pytest==9.1.1 + py3.13-elasticsearch-v8.19.3: pluggy==1.6.0 + py3.13-elasticsearch-v8.19.3: aiohttp==3.14.1 + py3.13-elasticsearch-v8.19.3: multidict==6.7.1 + py3.13-elasticsearch-v8.19.3: yarl==1.24.2 + py3.13-elasticsearch-v8.19.3: aiohappyeyeballs==2.7.1 + py3.13-elasticsearch-v8.19.3: aiosignal==1.4.0 + py3.13-elasticsearch-v8.19.3: attrs==26.1.0 + py3.13-elasticsearch-v8.19.3: frozenlist==1.8.0 + py3.13-elasticsearch-v8.19.3: idna==3.18 + py3.13-elasticsearch-v8.19.3: iniconfig==2.3.0 + py3.13-elasticsearch-v8.19.3: packaging==26.2 + py3.13-elasticsearch-v8.19.3: propcache==0.5.2 + py3.13-elasticsearch-v8.19.3: Pygments==2.20.0 + py3.13-elasticsearch-v8.19.3: certifi==2026.6.17 + py3.13-elasticsearch-v8.19.3: python-dateutil==2.9.0.post0 + py3.13-elasticsearch-v8.19.3: six==1.17.0 + py3.13-elasticsearch-v8.19.3: typing_extensions==4.16.0 + + elasticsearch-v9.4.1: elasticsearch==9.4.1 + + py3.10-elasticsearch-v9.4.1: pytest-asyncio==1.4.0 + py3.10-elasticsearch-v9.4.1: aiohttp==3.14.1 + py3.10-elasticsearch-v9.4.1: aiohappyeyeballs==2.7.1 + py3.10-elasticsearch-v9.4.1: aiosignal==1.4.0 + py3.10-elasticsearch-v9.4.1: async-timeout==5.0.1 + py3.10-elasticsearch-v9.4.1: attrs==26.1.0 + py3.10-elasticsearch-v9.4.1: backports.asyncio.runner==1.2.0 + py3.10-elasticsearch-v9.4.1: elastic-transport==9.4.2 + py3.10-elasticsearch-v9.4.1: frozenlist==1.8.0 + py3.10-elasticsearch-v9.4.1: multidict==6.7.1 + py3.10-elasticsearch-v9.4.1: propcache==0.5.2 + py3.10-elasticsearch-v9.4.1: pytest==9.1.1 + py3.10-elasticsearch-v9.4.1: typing_extensions==4.16.0 + py3.10-elasticsearch-v9.4.1: yarl==1.24.2 + py3.10-elasticsearch-v9.4.1: anyio==4.14.1 + py3.10-elasticsearch-v9.4.1: python-dateutil==2.9.0.post0 + py3.10-elasticsearch-v9.4.1: sniffio==1.3.1 + py3.10-elasticsearch-v9.4.1: exceptiongroup==1.3.1 + py3.10-elasticsearch-v9.4.1: idna==3.18 + py3.10-elasticsearch-v9.4.1: iniconfig==2.3.0 + py3.10-elasticsearch-v9.4.1: packaging==26.2 + py3.10-elasticsearch-v9.4.1: pluggy==1.6.0 + py3.10-elasticsearch-v9.4.1: Pygments==2.20.0 + py3.10-elasticsearch-v9.4.1: six==1.17.0 + py3.10-elasticsearch-v9.4.1: tomli==2.4.1 + py3.10-elasticsearch-v9.4.1: urllib3==2.7.0 + py3.10-elasticsearch-v9.4.1: certifi==2026.6.17 + + py3.13-elasticsearch-v9.4.1: elastic-transport==9.4.2 + py3.13-elasticsearch-v9.4.1: urllib3==2.7.0 + py3.13-elasticsearch-v9.4.1: pytest-asyncio==1.4.0 + py3.13-elasticsearch-v9.4.1: pytest==9.1.1 + py3.13-elasticsearch-v9.4.1: pluggy==1.6.0 + py3.13-elasticsearch-v9.4.1: aiohttp==3.14.1 + py3.13-elasticsearch-v9.4.1: multidict==6.7.1 + py3.13-elasticsearch-v9.4.1: yarl==1.24.2 + py3.13-elasticsearch-v9.4.1: aiohappyeyeballs==2.7.1 + py3.13-elasticsearch-v9.4.1: aiosignal==1.4.0 + py3.13-elasticsearch-v9.4.1: attrs==26.1.0 + py3.13-elasticsearch-v9.4.1: frozenlist==1.8.0 + py3.13-elasticsearch-v9.4.1: idna==3.18 + py3.13-elasticsearch-v9.4.1: iniconfig==2.3.0 + py3.13-elasticsearch-v9.4.1: packaging==26.2 + py3.13-elasticsearch-v9.4.1: propcache==0.5.2 + py3.13-elasticsearch-v9.4.1: Pygments==2.20.0 + py3.13-elasticsearch-v9.4.1: anyio==4.14.1 + py3.13-elasticsearch-v9.4.1: certifi==2026.6.17 + py3.13-elasticsearch-v9.4.1: python-dateutil==2.9.0.post0 + py3.13-elasticsearch-v9.4.1: six==1.17.0 + py3.13-elasticsearch-v9.4.1: sniffio==1.3.1 + py3.13-elasticsearch-v9.4.1: typing_extensions==4.16.0 + + py3.14-elasticsearch-v9.4.1: elastic-transport==9.4.2 + py3.14-elasticsearch-v9.4.1: urllib3==2.7.0 + py3.14-elasticsearch-v9.4.1: pytest-asyncio==1.4.0 + py3.14-elasticsearch-v9.4.1: pytest==9.1.1 + py3.14-elasticsearch-v9.4.1: pluggy==1.6.0 + py3.14-elasticsearch-v9.4.1: aiohttp==3.14.1 + py3.14-elasticsearch-v9.4.1: multidict==6.7.1 + py3.14-elasticsearch-v9.4.1: yarl==1.24.2 + py3.14-elasticsearch-v9.4.1: aiohappyeyeballs==2.7.1 + py3.14-elasticsearch-v9.4.1: aiosignal==1.4.0 + py3.14-elasticsearch-v9.4.1: attrs==26.1.0 + py3.14-elasticsearch-v9.4.1: frozenlist==1.8.0 + py3.14-elasticsearch-v9.4.1: idna==3.18 + py3.14-elasticsearch-v9.4.1: iniconfig==2.3.0 + py3.14-elasticsearch-v9.4.1: packaging==26.2 + py3.14-elasticsearch-v9.4.1: propcache==0.5.2 + py3.14-elasticsearch-v9.4.1: Pygments==2.20.0 + py3.14-elasticsearch-v9.4.1: anyio==4.14.1 + py3.14-elasticsearch-v9.4.1: certifi==2026.6.17 + py3.14-elasticsearch-v9.4.1: python-dateutil==2.9.0.post0 + py3.14-elasticsearch-v9.4.1: six==1.17.0 + py3.14-elasticsearch-v9.4.1: sniffio==1.3.1 + py3.14-elasticsearch-v9.4.1: typing_extensions==4.16.0 + + py3.14t-elasticsearch-v9.4.1: elastic-transport==9.4.2 + py3.14t-elasticsearch-v9.4.1: urllib3==2.7.0 + py3.14t-elasticsearch-v9.4.1: pytest-asyncio==1.4.0 + py3.14t-elasticsearch-v9.4.1: pytest==9.1.1 + py3.14t-elasticsearch-v9.4.1: pluggy==1.6.0 + py3.14t-elasticsearch-v9.4.1: aiohttp==3.14.1 + py3.14t-elasticsearch-v9.4.1: multidict==6.7.1 + py3.14t-elasticsearch-v9.4.1: yarl==1.24.2 + py3.14t-elasticsearch-v9.4.1: aiohappyeyeballs==2.7.1 + py3.14t-elasticsearch-v9.4.1: aiosignal==1.4.0 + py3.14t-elasticsearch-v9.4.1: attrs==26.1.0 + py3.14t-elasticsearch-v9.4.1: frozenlist==1.8.0 + py3.14t-elasticsearch-v9.4.1: idna==3.18 + py3.14t-elasticsearch-v9.4.1: iniconfig==2.3.0 + py3.14t-elasticsearch-v9.4.1: packaging==26.2 + py3.14t-elasticsearch-v9.4.1: propcache==0.5.2 + py3.14t-elasticsearch-v9.4.1: Pygments==2.20.0 + py3.14t-elasticsearch-v9.4.1: anyio==4.14.1 + py3.14t-elasticsearch-v9.4.1: certifi==2026.6.17 + py3.14t-elasticsearch-v9.4.1: python-dateutil==2.9.0.post0 + py3.14t-elasticsearch-v9.4.1: six==1.17.0 + py3.14t-elasticsearch-v9.4.1: sniffio==1.3.1 + py3.14t-elasticsearch-v9.4.1: typing_extensions==4.16.0 + + elasticsearch-latest: elasticsearch==9.4.1 + + py3.10-elasticsearch-latest: pytest-asyncio==1.4.0 + py3.10-elasticsearch-latest: aiohttp==3.14.1 + py3.10-elasticsearch-latest: aiohappyeyeballs==2.7.1 + py3.10-elasticsearch-latest: aiosignal==1.4.0 + py3.10-elasticsearch-latest: async-timeout==5.0.1 + py3.10-elasticsearch-latest: attrs==26.1.0 + py3.10-elasticsearch-latest: backports.asyncio.runner==1.2.0 + py3.10-elasticsearch-latest: elastic-transport==9.4.2 + py3.10-elasticsearch-latest: frozenlist==1.8.0 + py3.10-elasticsearch-latest: multidict==6.7.1 + py3.10-elasticsearch-latest: propcache==0.5.2 + py3.10-elasticsearch-latest: pytest==9.1.1 + py3.10-elasticsearch-latest: typing_extensions==4.16.0 + py3.10-elasticsearch-latest: yarl==1.24.2 + py3.10-elasticsearch-latest: anyio==4.14.1 + py3.10-elasticsearch-latest: python-dateutil==2.9.0.post0 + py3.10-elasticsearch-latest: sniffio==1.3.1 + py3.10-elasticsearch-latest: exceptiongroup==1.3.1 + py3.10-elasticsearch-latest: idna==3.18 + py3.10-elasticsearch-latest: iniconfig==2.3.0 + py3.10-elasticsearch-latest: packaging==26.2 + py3.10-elasticsearch-latest: pluggy==1.6.0 + py3.10-elasticsearch-latest: Pygments==2.20.0 + py3.10-elasticsearch-latest: six==1.17.0 + py3.10-elasticsearch-latest: tomli==2.4.1 + py3.10-elasticsearch-latest: urllib3==2.7.0 + py3.10-elasticsearch-latest: certifi==2026.6.17 + + py3.13-elasticsearch-latest: elastic-transport==9.4.2 + py3.13-elasticsearch-latest: urllib3==2.7.0 + py3.13-elasticsearch-latest: pytest-asyncio==1.4.0 + py3.13-elasticsearch-latest: pytest==9.1.1 + py3.13-elasticsearch-latest: pluggy==1.6.0 + py3.13-elasticsearch-latest: aiohttp==3.14.1 + py3.13-elasticsearch-latest: multidict==6.7.1 + py3.13-elasticsearch-latest: yarl==1.24.2 + py3.13-elasticsearch-latest: aiohappyeyeballs==2.7.1 + py3.13-elasticsearch-latest: aiosignal==1.4.0 + py3.13-elasticsearch-latest: attrs==26.1.0 + py3.13-elasticsearch-latest: frozenlist==1.8.0 + py3.13-elasticsearch-latest: idna==3.18 + py3.13-elasticsearch-latest: iniconfig==2.3.0 + py3.13-elasticsearch-latest: packaging==26.2 + py3.13-elasticsearch-latest: propcache==0.5.2 + py3.13-elasticsearch-latest: Pygments==2.20.0 + py3.13-elasticsearch-latest: anyio==4.14.1 + py3.13-elasticsearch-latest: certifi==2026.6.17 + py3.13-elasticsearch-latest: python-dateutil==2.9.0.post0 + py3.13-elasticsearch-latest: six==1.17.0 + py3.13-elasticsearch-latest: sniffio==1.3.1 + py3.13-elasticsearch-latest: typing_extensions==4.16.0 + + py3.14-elasticsearch-latest: elastic-transport==9.4.2 + py3.14-elasticsearch-latest: urllib3==2.7.0 + py3.14-elasticsearch-latest: pytest-asyncio==1.4.0 + py3.14-elasticsearch-latest: pytest==9.1.1 + py3.14-elasticsearch-latest: pluggy==1.6.0 + py3.14-elasticsearch-latest: aiohttp==3.14.1 + py3.14-elasticsearch-latest: multidict==6.7.1 + py3.14-elasticsearch-latest: yarl==1.24.2 + py3.14-elasticsearch-latest: aiohappyeyeballs==2.7.1 + py3.14-elasticsearch-latest: aiosignal==1.4.0 + py3.14-elasticsearch-latest: attrs==26.1.0 + py3.14-elasticsearch-latest: frozenlist==1.8.0 + py3.14-elasticsearch-latest: idna==3.18 + py3.14-elasticsearch-latest: iniconfig==2.3.0 + py3.14-elasticsearch-latest: packaging==26.2 + py3.14-elasticsearch-latest: propcache==0.5.2 + py3.14-elasticsearch-latest: Pygments==2.20.0 + py3.14-elasticsearch-latest: anyio==4.14.1 + py3.14-elasticsearch-latest: certifi==2026.6.17 + py3.14-elasticsearch-latest: python-dateutil==2.9.0.post0 + py3.14-elasticsearch-latest: six==1.17.0 + py3.14-elasticsearch-latest: sniffio==1.3.1 + py3.14-elasticsearch-latest: typing_extensions==4.16.0 + + py3.14t-elasticsearch-latest: elastic-transport==9.4.2 + py3.14t-elasticsearch-latest: urllib3==2.7.0 + py3.14t-elasticsearch-latest: pytest-asyncio==1.4.0 + py3.14t-elasticsearch-latest: pytest==9.1.1 + py3.14t-elasticsearch-latest: pluggy==1.6.0 + py3.14t-elasticsearch-latest: aiohttp==3.14.1 + py3.14t-elasticsearch-latest: multidict==6.7.1 + py3.14t-elasticsearch-latest: yarl==1.24.2 + py3.14t-elasticsearch-latest: aiohappyeyeballs==2.7.1 + py3.14t-elasticsearch-latest: aiosignal==1.4.0 + py3.14t-elasticsearch-latest: attrs==26.1.0 + py3.14t-elasticsearch-latest: frozenlist==1.8.0 + py3.14t-elasticsearch-latest: idna==3.18 + py3.14t-elasticsearch-latest: iniconfig==2.3.0 + py3.14t-elasticsearch-latest: packaging==26.2 + py3.14t-elasticsearch-latest: propcache==0.5.2 + py3.14t-elasticsearch-latest: Pygments==2.20.0 + py3.14t-elasticsearch-latest: anyio==4.14.1 + py3.14t-elasticsearch-latest: certifi==2026.6.17 + py3.14t-elasticsearch-latest: python-dateutil==2.9.0.post0 + py3.14t-elasticsearch-latest: six==1.17.0 + py3.14t-elasticsearch-latest: sniffio==1.3.1 + py3.14t-elasticsearch-latest: typing_extensions==4.16.0 + + elasticsearch: pytest-asyncio + elasticsearch: aiohttp + pymongo-v3.5.1: pymongo==3.5.1 pymongo-v3.13.0: pymongo==3.13.0 @@ -18646,6 +18978,7 @@ setenv = cohere: _TESTPATH=tests/integrations/cohere django: _TESTPATH=tests/integrations/django dramatiq: _TESTPATH=tests/integrations/dramatiq + elasticsearch: _TESTPATH=tests/integrations/elasticsearch falcon: _TESTPATH=tests/integrations/falcon fastapi: _TESTPATH=tests/integrations/fastapi fastmcp: _TESTPATH=tests/integrations/fastmcp