From 173df643f8847e8e0bb0a59da7cda0a019af283e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Mon, 1 Sep 2025 15:19:24 +0200 Subject: [PATCH 01/26] Update tox.ini (#4731) Regular update --- tox.ini | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tox.ini b/tox.ini index bbc1d57c12..0dbcef2c64 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-08-26T08:59:42.512502+00:00 +# Last generated: 2025-09-01T12:08:33.833560+00:00 [tox] requires = @@ -147,7 +147,7 @@ envlist = {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 - {py3.10,py3.12,py3.13}-openai_agents-v0.2.9 + {py3.10,py3.12,py3.13}-openai_agents-v0.2.10 {py3.8,py3.10,py3.11}-huggingface_hub-v0.22.2 {py3.8,py3.11,py3.12}-huggingface_hub-v0.26.5 @@ -210,7 +210,7 @@ envlist = {py3.8,py3.10,py3.11}-strawberry-v0.209.8 {py3.8,py3.11,py3.12}-strawberry-v0.233.3 {py3.9,py3.12,py3.13}-strawberry-v0.257.0 - {py3.9,py3.12,py3.13}-strawberry-v0.280.0 + {py3.9,py3.12,py3.13}-strawberry-v0.281.0 # ~~~ Network ~~~ @@ -218,6 +218,7 @@ envlist = {py3.7,py3.9,py3.10}-grpc-v1.46.5 {py3.7,py3.11,py3.12}-grpc-v1.60.2 {py3.9,py3.12,py3.13}-grpc-v1.74.0 + {py3.9,py3.12,py3.13}-grpc-v1.75.0rc1 # ~~~ Tasks ~~~ @@ -311,6 +312,7 @@ envlist = {py3.7,py3.12,py3.13}-typer-v0.15.4 {py3.7,py3.12,py3.13}-typer-v0.16.1 + {py3.7,py3.12,py3.13}-typer-v0.17.3 @@ -527,7 +529,7 @@ deps = openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 - openai_agents-v0.2.9: openai-agents==0.2.9 + openai_agents-v0.2.10: openai-agents==0.2.10 openai_agents: pytest-asyncio huggingface_hub-v0.22.2: huggingface_hub==0.22.2 @@ -601,7 +603,7 @@ deps = strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 strawberry-v0.233.3: strawberry-graphql[fastapi,flask]==0.233.3 strawberry-v0.257.0: strawberry-graphql[fastapi,flask]==0.257.0 - strawberry-v0.280.0: strawberry-graphql[fastapi,flask]==0.280.0 + strawberry-v0.281.0: strawberry-graphql[fastapi,flask]==0.281.0 strawberry: httpx strawberry-v0.209.8: pydantic<2.11 strawberry-v0.233.3: pydantic<2.11 @@ -613,6 +615,7 @@ deps = grpc-v1.46.5: grpcio==1.46.5 grpc-v1.60.2: grpcio==1.60.2 grpc-v1.74.0: grpcio==1.74.0 + grpc-v1.75.0rc1: grpcio==1.75.0rc1 grpc: protobuf grpc: mypy-protobuf grpc: types-protobuf @@ -782,6 +785,7 @@ deps = typer-v0.15.4: typer==0.15.4 typer-v0.16.1: typer==0.16.1 + typer-v0.17.3: typer==0.17.3 From 1d473b62490508cc3f8070bdaccc2e5bd20b182a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 2 Sep 2025 10:44:22 +0200 Subject: [PATCH 02/26] toxgen: Add variants & move OpenAI under toxgen (#4730) Adds supports for variants, i.e., the same test suite running with a slightly different setup (for instance, a different set of dependencies, like `openai` and `openai_notiktoken`). To add a variant, simply add a new test suite to the config. The tricky part is naming. I had to rename `openai` to `openai_base` since otherwise the `openai_notiktoken` and `openai_agents` test suite would be run with `tox -e py-openai` / `./scripts/runtox.sh py-openai` due to how tox works. They should be treated as three different suites. Closes https://github.com/getsentry/sentry-python/issues/4507 --- .github/workflows/test-integrations-ai.yml | 16 ++++-- scripts/populate_tox/README.md | 8 +++ scripts/populate_tox/config.py | 18 +++++++ scripts/populate_tox/populate_tox.py | 8 +-- scripts/populate_tox/tox.jinja | 24 +-------- .../split_tox_gh_actions.py | 3 +- tox.ini | 53 +++++++++++-------- 7 files changed, 77 insertions(+), 53 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index a784f9fc47..a6995fa268 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -62,10 +62,14 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-langchain-latest" - - name: Test openai latest + - name: Test openai_base latest run: | set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-openai-latest" + ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_base-latest" + - name: Test openai_notiktoken latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_notiktoken-latest" - name: Test openai_agents latest run: | set -x # print commands that are executed @@ -141,10 +145,14 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langchain" - - name: Test openai pinned + - name: Test openai_base pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai_base" + - name: Test openai_notiktoken pinned run: | set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai" + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai_notiktoken" - name: Test openai_agents pinned run: | set -x # print commands that are executed diff --git a/scripts/populate_tox/README.md b/scripts/populate_tox/README.md index c9a3b67ba0..c48d57734d 100644 --- a/scripts/populate_tox/README.md +++ b/scripts/populate_tox/README.md @@ -153,6 +153,14 @@ be expressed like so: } ``` +### `integration_name` + +Sometimes, the name of the test suite doesn't match the name of the integration. +For example, we have the `openai_base` and `openai_notiktoken` test suites, both +of which are actually testing the `openai` integration. If this is the case, you can use the `integration_name` key to define the name of the integration. If not provided, it will default to the name of the test suite. + +Linking an integration to a test suite allows the script to access integration configuration like for example the minimum version defined in `sentry_sdk/integrations/__init__.py`. + ## How-Tos diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index f395289b4a..65e463a947 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -139,6 +139,24 @@ "loguru": { "package": "loguru", }, + "openai_base": { + "package": "openai", + "integration_name": "openai", + "deps": { + "*": ["pytest-asyncio", "tiktoken"], + "<1.55": ["httpx<0.28"], + }, + "python": ">=3.8", + }, + "openai_notiktoken": { + "package": "openai", + "integration_name": "openai", + "deps": { + "*": ["pytest-asyncio"], + "<1.55": ["httpx<0.28"], + }, + "python": ">=3.8", + }, "openai_agents": { "package": "openai-agents", "deps": { diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 3ca5ab18c8..53d5609d50 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -76,8 +76,6 @@ "httpx", "langchain", "langchain_notiktoken", - "openai", - "openai_notiktoken", "pure_eval", "quart", "ray", @@ -141,7 +139,11 @@ def _prefilter_releases( - the list of prefiltered releases - an optional prerelease if there is one that should be tested """ - min_supported = _MIN_VERSIONS.get(integration) + integration_name = ( + TEST_SUITE_CONFIG[integration].get("integration_name") or integration + ) + + min_supported = _MIN_VERSIONS.get(integration_name) if min_supported is not None: min_supported = Version(".".join(map(str, min_supported))) else: diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 4c3b86af81..632ce7c71b 100644 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -83,13 +83,6 @@ envlist = {py3.9,py3.11,py3.12}-langchain-latest {py3.9,py3.11,py3.12}-langchain-notiktoken - # OpenAI - {py3.9,py3.11,py3.12}-openai-v1.0 - {py3.9,py3.11,py3.12}-openai-v1.22 - {py3.9,py3.11,py3.12}-openai-v1.55 - {py3.9,py3.11,py3.12}-openai-latest - {py3.9,py3.11,py3.12}-openai-notiktoken - # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13}-opentelemetry @@ -252,20 +245,6 @@ deps = langchain-{latest,notiktoken}: openai>=1.6.1 langchain-latest: tiktoken~=0.6.0 - # OpenAI - openai: pytest-asyncio - openai-v1.0: openai~=1.0.0 - openai-v1.0: tiktoken - openai-v1.0: httpx<0.28.0 - openai-v1.22: openai~=1.22.0 - openai-v1.22: tiktoken - openai-v1.22: httpx<0.28.0 - openai-v1.55: openai~=1.55.0 - openai-v1.55: tiktoken - openai-latest: openai - openai-latest: tiktoken~=0.6.0 - openai-notiktoken: openai - # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -401,7 +380,8 @@ setenv = launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru - openai: TESTPATH=tests/integrations/openai + openai_base: TESTPATH=tests/integrations/openai + openai_notiktoken: TESTPATH=tests/integrations/openai openai_agents: TESTPATH=tests/integrations/openai_agents openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry 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 af1ff84cd6..305ceeae76 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -62,7 +62,8 @@ "anthropic", "cohere", "langchain", - "openai", + "openai_base", + "openai_notiktoken", "openai_agents", "huggingface_hub", ], diff --git a/tox.ini b/tox.ini index 0dbcef2c64..eea115876b 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-01T12:08:33.833560+00:00 +# Last generated: 2025-09-01T14:09:50.564158+00:00 [tox] requires = @@ -83,13 +83,6 @@ envlist = {py3.9,py3.11,py3.12}-langchain-latest {py3.9,py3.11,py3.12}-langchain-notiktoken - # OpenAI - {py3.9,py3.11,py3.12}-openai-v1.0 - {py3.9,py3.11,py3.12}-openai-v1.22 - {py3.9,py3.11,py3.12}-openai-v1.55 - {py3.9,py3.11,py3.12}-openai-latest - {py3.9,py3.11,py3.12}-openai-notiktoken - # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13}-opentelemetry @@ -145,6 +138,16 @@ envlist = {py3.9,py3.11,py3.12}-cohere-v5.13.12 {py3.9,py3.11,py3.12}-cohere-v5.17.0 + {py3.8,py3.11,py3.12}-openai_base-v1.0.1 + {py3.8,py3.11,py3.12}-openai_base-v1.35.15 + {py3.8,py3.11,py3.12}-openai_base-v1.69.0 + {py3.8,py3.12,py3.13}-openai_base-v1.102.0 + + {py3.8,py3.11,py3.12}-openai_notiktoken-v1.0.1 + {py3.8,py3.11,py3.12}-openai_notiktoken-v1.35.15 + {py3.8,py3.11,py3.12}-openai_notiktoken-v1.69.0 + {py3.8,py3.12,py3.13}-openai_notiktoken-v1.102.0 + {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 {py3.10,py3.12,py3.13}-openai_agents-v0.2.10 @@ -423,20 +426,6 @@ deps = langchain-{latest,notiktoken}: openai>=1.6.1 langchain-latest: tiktoken~=0.6.0 - # OpenAI - openai: pytest-asyncio - openai-v1.0: openai~=1.0.0 - openai-v1.0: tiktoken - openai-v1.0: httpx<0.28.0 - openai-v1.22: openai~=1.22.0 - openai-v1.22: tiktoken - openai-v1.22: httpx<0.28.0 - openai-v1.55: openai~=1.55.0 - openai-v1.55: tiktoken - openai-latest: openai - openai-latest: tiktoken~=0.6.0 - openai-notiktoken: openai - # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -527,6 +516,23 @@ deps = cohere-v5.13.12: cohere==5.13.12 cohere-v5.17.0: cohere==5.17.0 + openai_base-v1.0.1: openai==1.0.1 + openai_base-v1.35.15: openai==1.35.15 + openai_base-v1.69.0: openai==1.69.0 + openai_base-v1.102.0: openai==1.102.0 + openai_base: pytest-asyncio + openai_base: tiktoken + openai_base-v1.0.1: httpx<0.28 + openai_base-v1.35.15: httpx<0.28 + + openai_notiktoken-v1.0.1: openai==1.0.1 + openai_notiktoken-v1.35.15: openai==1.35.15 + openai_notiktoken-v1.69.0: openai==1.69.0 + openai_notiktoken-v1.102.0: openai==1.102.0 + openai_notiktoken: pytest-asyncio + openai_notiktoken-v1.0.1: httpx<0.28 + openai_notiktoken-v1.35.15: httpx<0.28 + openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 openai_agents-v0.2.10: openai-agents==0.2.10 @@ -831,7 +837,8 @@ setenv = launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru - openai: TESTPATH=tests/integrations/openai + openai_base: TESTPATH=tests/integrations/openai + openai_notiktoken: TESTPATH=tests/integrations/openai openai_agents: TESTPATH=tests/integrations/openai_agents openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry From 65755f95351581bd89101ce8eba9ff4768c9474e Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 2 Sep 2025 13:42:55 +0200 Subject: [PATCH 03/26] tests: Move langchain under toxgen (#4734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - move the langchain test suites to be governed by toxgen - this indirectly results in removing the `-latest` tests in the AI group 🎉 - `-latest` tests predate toxgen and langchain was the last non-toxgen test suite (the rest was just skipped) -- all AI tests are now pinned - updated the naming scheme to use dashes instead of underscores for variants so that it's clearer if something is part of the name of the integration or if it denotes a variant - for instance, `openai-base` means this is the `base` variant of the `openai` test suite, but `openai_agents` means this is the `openai_agents` test suite (no variant) I'm explicitly ignoring the two alpha versions of 1.0 since adapting the integration to work with those is out of scope: [dedicated issue](https://github.com/getsentry/sentry-python/issues/4735) Part of https://github.com/getsentry/sentry-python/issues/4506 --- .github/workflows/test-integrations-ai.yml | 99 ++--------------- scripts/populate_tox/config.py | 24 ++++- scripts/populate_tox/tox.jinja | 27 +---- .../split_tox_gh_actions.py | 7 +- sentry_sdk/integrations/__init__.py | 2 +- tox.ini | 100 +++++++++--------- 6 files changed, 93 insertions(+), 166 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index a6995fa268..72a4253744 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -22,89 +22,6 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-ai-latest: - name: AI (latest) - timeout-minutes: 30 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ["3.9","3.11","3.12"] - # python3.6 reached EOL and is no longer being supported on - # new versions of hosted runners on Github Actions - # ubuntu-20.04 is the last version that supported python3.6 - # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 - os: [ubuntu-22.04] - # Use Docker container only for Python 3.6 - container: ${{ matrix.python-version == '3.6' && 'python:3.6' || null }} - steps: - - uses: actions/checkout@v5.0.0 - - uses: actions/setup-python@v5 - if: ${{ matrix.python-version != '3.6' }} - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - name: Setup Test Env - run: | - pip install "coverage[toml]" tox - - name: Erase coverage - run: | - coverage erase - - name: Test anthropic latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-anthropic-latest" - - name: Test cohere latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-cohere-latest" - - name: Test langchain latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-langchain-latest" - - name: Test openai_base latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_base-latest" - - name: Test openai_notiktoken latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_notiktoken-latest" - - name: Test openai_agents latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-openai_agents-latest" - - name: Test huggingface_hub latest - run: | - set -x # print commands that are executed - ./scripts/runtox.sh "py${{ matrix.python-version }}-huggingface_hub-latest" - - name: Generate coverage XML (Python 3.6) - if: ${{ !cancelled() && matrix.python-version == '3.6' }} - run: | - export COVERAGE_RCFILE=.coveragerc36 - coverage combine .coverage-sentry-* - coverage xml --ignore-errors - - name: Generate coverage XML - if: ${{ !cancelled() && matrix.python-version != '3.6' }} - run: | - coverage combine .coverage-sentry-* - coverage xml - - name: Upload coverage to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.5.0 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage.xml - # make sure no plugins alter our coverage reports - plugins: noop - verbose: true - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: .junitxml - verbose: true test-ai-pinned: name: AI (pinned) timeout-minutes: 30 @@ -141,18 +58,22 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-cohere" - - name: Test langchain pinned + - name: Test langchain-base pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langchain-base" + - name: Test langchain-notiktoken pinned run: | set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langchain" - - name: Test openai_base pinned + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langchain-notiktoken" + - name: Test openai-base pinned run: | set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai_base" - - name: Test openai_notiktoken pinned + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai-base" + - name: Test openai-notiktoken pinned run: | set -x # print commands that are executed - ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai_notiktoken" + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai-notiktoken" - name: Test openai_agents pinned run: | set -x # print commands that are executed diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 65e463a947..0d4d0fe6ee 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -126,6 +126,26 @@ "huggingface_hub": { "package": "huggingface_hub", }, + "langchain-base": { + "package": "langchain", + "integration_name": "langchain", + "deps": { + "*": ["openai", "tiktoken", "langchain-openai"], + "<=0.1": ["httpx<0.28.0"], + ">=0.3": ["langchain-community"], + }, + "include": "<1.0", + }, + "langchain-notiktoken": { + "package": "langchain", + "integration_name": "langchain", + "deps": { + "*": ["openai", "langchain-openai"], + "<=0.1": ["httpx<0.28.0"], + ">=0.3": ["langchain-community"], + }, + "include": "<1.0", + }, "launchdarkly": { "package": "launchdarkly-server-sdk", }, @@ -139,7 +159,7 @@ "loguru": { "package": "loguru", }, - "openai_base": { + "openai-base": { "package": "openai", "integration_name": "openai", "deps": { @@ -148,7 +168,7 @@ }, "python": ">=3.8", }, - "openai_notiktoken": { + "openai-notiktoken": { "package": "openai", "integration_name": "openai", "deps": { diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 632ce7c71b..2b968b7aa1 100644 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -77,12 +77,6 @@ envlist = {py3.9,py3.11,py3.12}-httpx-v{0.25,0.27} {py3.9,py3.12,py3.13}-httpx-latest - # Langchain - {py3.9,py3.11,py3.12}-langchain-v0.1 - {py3.9,py3.11,py3.12}-langchain-v0.3 - {py3.9,py3.11,py3.12}-langchain-latest - {py3.9,py3.11,py3.12}-langchain-notiktoken - # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13}-opentelemetry @@ -231,20 +225,6 @@ deps = httpx-v0.27: httpx~=0.27.0 httpx-latest: httpx - # Langchain - langchain-v0.1: openai~=1.0.0 - langchain-v0.1: langchain~=0.1.11 - langchain-v0.1: tiktoken~=0.6.0 - langchain-v0.1: httpx<0.28.0 - langchain-v0.3: langchain~=0.3.0 - langchain-v0.3: langchain-community - langchain-v0.3: tiktoken - langchain-v0.3: openai - langchain-{latest,notiktoken}: langchain - langchain-{latest,notiktoken}: langchain-openai - langchain-{latest,notiktoken}: openai>=1.6.1 - langchain-latest: tiktoken~=0.6.0 - # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -376,12 +356,13 @@ setenv = httpx: TESTPATH=tests/integrations/httpx huey: TESTPATH=tests/integrations/huey huggingface_hub: TESTPATH=tests/integrations/huggingface_hub - langchain: TESTPATH=tests/integrations/langchain + langchain-base: TESTPATH=tests/integrations/langchain + langchain-notiktoken: TESTPATH=tests/integrations/langchain launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru - openai_base: TESTPATH=tests/integrations/openai - openai_notiktoken: TESTPATH=tests/integrations/openai + openai-base: TESTPATH=tests/integrations/openai + openai-notiktoken: TESTPATH=tests/integrations/openai openai_agents: TESTPATH=tests/integrations/openai_agents openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry 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 305ceeae76..1c3435f43b 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -61,9 +61,10 @@ "AI": [ "anthropic", "cohere", - "langchain", - "openai_base", - "openai_notiktoken", + "langchain-base", + "langchain-notiktoken", + "openai-base", + "openai-notiktoken", "openai_agents", "huggingface_hub", ], diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index e2eadd523d..6f0109aced 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -141,7 +141,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "graphene": (3, 3), "grpc": (1, 32, 0), # grpcio "huggingface_hub": (0, 22), - "langchain": (0, 0, 210), + "langchain": (0, 1, 0), "launchdarkly": (9, 8, 0), "loguru": (0, 7, 0), "openai": (1, 0, 0), diff --git a/tox.ini b/tox.ini index eea115876b..0898bc888f 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-01T14:09:50.564158+00:00 +# Last generated: 2025-09-02T10:59:55.249513+00:00 [tox] requires = @@ -77,12 +77,6 @@ envlist = {py3.9,py3.11,py3.12}-httpx-v{0.25,0.27} {py3.9,py3.12,py3.13}-httpx-latest - # Langchain - {py3.9,py3.11,py3.12}-langchain-v0.1 - {py3.9,py3.11,py3.12}-langchain-v0.3 - {py3.9,py3.11,py3.12}-langchain-latest - {py3.9,py3.11,py3.12}-langchain-notiktoken - # OpenTelemetry (OTel) {py3.7,py3.9,py3.12,py3.13}-opentelemetry @@ -138,15 +132,23 @@ envlist = {py3.9,py3.11,py3.12}-cohere-v5.13.12 {py3.9,py3.11,py3.12}-cohere-v5.17.0 - {py3.8,py3.11,py3.12}-openai_base-v1.0.1 - {py3.8,py3.11,py3.12}-openai_base-v1.35.15 - {py3.8,py3.11,py3.12}-openai_base-v1.69.0 - {py3.8,py3.12,py3.13}-openai_base-v1.102.0 + {py3.9,py3.11,py3.12}-langchain-base-v0.1.20 + {py3.9,py3.11,py3.12}-langchain-base-v0.2.17 + {py3.9,py3.12,py3.13}-langchain-base-v0.3.27 + + {py3.9,py3.11,py3.12}-langchain-notiktoken-v0.1.20 + {py3.9,py3.11,py3.12}-langchain-notiktoken-v0.2.17 + {py3.9,py3.12,py3.13}-langchain-notiktoken-v0.3.27 - {py3.8,py3.11,py3.12}-openai_notiktoken-v1.0.1 - {py3.8,py3.11,py3.12}-openai_notiktoken-v1.35.15 - {py3.8,py3.11,py3.12}-openai_notiktoken-v1.69.0 - {py3.8,py3.12,py3.13}-openai_notiktoken-v1.102.0 + {py3.8,py3.11,py3.12}-openai-base-v1.0.1 + {py3.8,py3.11,py3.12}-openai-base-v1.35.15 + {py3.8,py3.11,py3.12}-openai-base-v1.69.0 + {py3.8,py3.12,py3.13}-openai-base-v1.102.0 + + {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 + {py3.8,py3.11,py3.12}-openai-notiktoken-v1.35.15 + {py3.8,py3.11,py3.12}-openai-notiktoken-v1.69.0 + {py3.8,py3.12,py3.13}-openai-notiktoken-v1.102.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -412,20 +414,6 @@ deps = httpx-v0.27: httpx~=0.27.0 httpx-latest: httpx - # Langchain - langchain-v0.1: openai~=1.0.0 - langchain-v0.1: langchain~=0.1.11 - langchain-v0.1: tiktoken~=0.6.0 - langchain-v0.1: httpx<0.28.0 - langchain-v0.3: langchain~=0.3.0 - langchain-v0.3: langchain-community - langchain-v0.3: tiktoken - langchain-v0.3: openai - langchain-{latest,notiktoken}: langchain - langchain-{latest,notiktoken}: langchain-openai - langchain-{latest,notiktoken}: openai>=1.6.1 - langchain-latest: tiktoken~=0.6.0 - # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -516,22 +504,37 @@ deps = cohere-v5.13.12: cohere==5.13.12 cohere-v5.17.0: cohere==5.17.0 - openai_base-v1.0.1: openai==1.0.1 - openai_base-v1.35.15: openai==1.35.15 - openai_base-v1.69.0: openai==1.69.0 - openai_base-v1.102.0: openai==1.102.0 - openai_base: pytest-asyncio - openai_base: tiktoken - openai_base-v1.0.1: httpx<0.28 - openai_base-v1.35.15: httpx<0.28 - - openai_notiktoken-v1.0.1: openai==1.0.1 - openai_notiktoken-v1.35.15: openai==1.35.15 - openai_notiktoken-v1.69.0: openai==1.69.0 - openai_notiktoken-v1.102.0: openai==1.102.0 - openai_notiktoken: pytest-asyncio - openai_notiktoken-v1.0.1: httpx<0.28 - openai_notiktoken-v1.35.15: httpx<0.28 + langchain-base-v0.1.20: langchain==0.1.20 + langchain-base-v0.2.17: langchain==0.2.17 + langchain-base-v0.3.27: langchain==0.3.27 + langchain-base: openai + langchain-base: tiktoken + langchain-base: langchain-openai + langchain-base-v0.3.27: langchain-community + + langchain-notiktoken-v0.1.20: langchain==0.1.20 + langchain-notiktoken-v0.2.17: langchain==0.2.17 + langchain-notiktoken-v0.3.27: langchain==0.3.27 + langchain-notiktoken: openai + langchain-notiktoken: langchain-openai + langchain-notiktoken-v0.3.27: langchain-community + + openai-base-v1.0.1: openai==1.0.1 + openai-base-v1.35.15: openai==1.35.15 + openai-base-v1.69.0: openai==1.69.0 + openai-base-v1.102.0: openai==1.102.0 + openai-base: pytest-asyncio + openai-base: tiktoken + openai-base-v1.0.1: httpx<0.28 + openai-base-v1.35.15: httpx<0.28 + + openai-notiktoken-v1.0.1: openai==1.0.1 + openai-notiktoken-v1.35.15: openai==1.35.15 + openai-notiktoken-v1.69.0: openai==1.69.0 + openai-notiktoken-v1.102.0: openai==1.102.0 + openai-notiktoken: pytest-asyncio + openai-notiktoken-v1.0.1: httpx<0.28 + openai-notiktoken-v1.35.15: httpx<0.28 openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 @@ -833,12 +836,13 @@ setenv = httpx: TESTPATH=tests/integrations/httpx huey: TESTPATH=tests/integrations/huey huggingface_hub: TESTPATH=tests/integrations/huggingface_hub - langchain: TESTPATH=tests/integrations/langchain + langchain-base: TESTPATH=tests/integrations/langchain + langchain-notiktoken: TESTPATH=tests/integrations/langchain launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru - openai_base: TESTPATH=tests/integrations/openai - openai_notiktoken: TESTPATH=tests/integrations/openai + openai-base: TESTPATH=tests/integrations/openai + openai-notiktoken: TESTPATH=tests/integrations/openai openai_agents: TESTPATH=tests/integrations/openai_agents openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry From b1a8b6333ceafd116fbaf1f50e1e14967f5d9f94 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Tue, 2 Sep 2025 14:14:36 +0200 Subject: [PATCH 04/26] fix(openai): Avoid double exit causing an unraisable exception (#4736) Add parameter to the method capturing exceptions in the OpenAI integration, to determine if the span context is closed with __exit__() or not. The option is used to prevent double exit scenarios when a span context is managed automatically. Related to: https://github.com/getsentry/sentry-python/issues/4723 --- sentry_sdk/integrations/openai.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 187f795807..6ea545322c 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -78,12 +78,12 @@ def count_tokens(self, s): return 0 -def _capture_exception(exc): - # type: (Any) -> None +def _capture_exception(exc, manual_span_cleanup=True): + # type: (Any, bool) -> None # Close an eventually open span # We need to do this by hand because we are not using the start_span context manager current_span = sentry_sdk.get_current_span() - if current_span is not None: + if manual_span_cleanup and current_span is not None: current_span.__exit__(None, None, None) event, hint = event_from_exception( @@ -516,7 +516,7 @@ def _execute_sync(f, *args, **kwargs): try: result = f(*args, **kwargs) except Exception as e: - _capture_exception(e) + _capture_exception(e, manual_span_cleanup=False) raise e from None return gen.send(result) @@ -550,7 +550,7 @@ async def _execute_async(f, *args, **kwargs): try: result = await f(*args, **kwargs) except Exception as e: - _capture_exception(e) + _capture_exception(e, manual_span_cleanup=False) raise e from None return gen.send(result) From 9c4eb5e272910aafadd65e8ae92696034647e47f Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Tue, 2 Sep 2025 15:27:41 +0200 Subject: [PATCH 05/26] tests: Trigger Pytest failure when an unraisable exception occurs (#4738) Set Pytest command-line argument to return non-zero exit code when an unraisable exception is encountered. Closes https://github.com/getsentry/sentry-python/issues/4723. --- scripts/populate_tox/tox.jinja | 2 +- tox.ini | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) mode change 100644 => 100755 scripts/populate_tox/tox.jinja diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja old mode 100644 new mode 100755 index 2b968b7aa1..42c570b111 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -429,7 +429,7 @@ commands = ; Running `pytest` as an executable suffers from an import error ; when loading tests in scenarios. In particular, django fails to ; load the settings from the test module. - python -m pytest {env:TESTPATH} -o junit_suite_name={envname} {posargs} + python -m pytest -W error::pytest.PytestUnraisableExceptionWarning {env:TESTPATH} -o junit_suite_name={envname} {posargs} [testenv:linters] commands = diff --git a/tox.ini b/tox.ini index 0898bc888f..a8e66cb80f 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-02T10:59:55.249513+00:00 +# Last generated: 2025-09-02T12:34:09.591543+00:00 [tox] requires = @@ -909,7 +909,7 @@ commands = ; Running `pytest` as an executable suffers from an import error ; when loading tests in scenarios. In particular, django fails to ; load the settings from the test module. - python -m pytest {env:TESTPATH} -o junit_suite_name={envname} {posargs} + python -m pytest -W error::pytest.PytestUnraisableExceptionWarning {env:TESTPATH} -o junit_suite_name={envname} {posargs} [testenv:linters] commands = From c213abf4a4ea9a09f8387fd192e8ee1992851657 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Tue, 2 Sep 2025 16:01:12 +0200 Subject: [PATCH 06/26] Remove old langchain test suites from ignore list (#4737) Forgot to remove these two from the toxgen ignore list. Shouldn't have any actual effect on tests since the test suites are now called differently. --- scripts/populate_tox/populate_tox.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 53d5609d50..179a466944 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -74,8 +74,6 @@ "chalice", "gcp", "httpx", - "langchain", - "langchain_notiktoken", "pure_eval", "quart", "ray", From 4456351b9156fd44b5797583d37889ed2af70517 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Sep 2025 08:46:15 +0200 Subject: [PATCH 07/26] Fix `openai_agents` in CI (#4742) A new version of `openai`, which is a dependency of `openai_agents`, [came out an hour ago](https://pypi.org/project/openai/#history), which [broke](https://github.com/getsentry/sentry-python/actions/runs/17405958869/job/49410259073) our CI. Pinning for now. --- scripts/populate_tox/config.py | 1 + tox.ini | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 0d4d0fe6ee..69f7b02e21 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -181,6 +181,7 @@ "package": "openai-agents", "deps": { "*": ["pytest-asyncio"], + "<=0.2.10": ["openai<1.103.0"], }, "python": ">=3.10", }, diff --git a/tox.ini b/tox.ini index a8e66cb80f..c45c72bf85 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-02T12:34:09.591543+00:00 +# Last generated: 2025-09-02T14:49:13.002983+00:00 [tox] requires = @@ -143,12 +143,12 @@ envlist = {py3.8,py3.11,py3.12}-openai-base-v1.0.1 {py3.8,py3.11,py3.12}-openai-base-v1.35.15 {py3.8,py3.11,py3.12}-openai-base-v1.69.0 - {py3.8,py3.12,py3.13}-openai-base-v1.102.0 + {py3.8,py3.12,py3.13}-openai-base-v1.103.0 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.35.15 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.69.0 - {py3.8,py3.12,py3.13}-openai-notiktoken-v1.102.0 + {py3.8,py3.12,py3.13}-openai-notiktoken-v1.103.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -522,7 +522,7 @@ deps = openai-base-v1.0.1: openai==1.0.1 openai-base-v1.35.15: openai==1.35.15 openai-base-v1.69.0: openai==1.69.0 - openai-base-v1.102.0: openai==1.102.0 + openai-base-v1.103.0: openai==1.103.0 openai-base: pytest-asyncio openai-base: tiktoken openai-base-v1.0.1: httpx<0.28 @@ -531,7 +531,7 @@ deps = openai-notiktoken-v1.0.1: openai==1.0.1 openai-notiktoken-v1.35.15: openai==1.35.15 openai-notiktoken-v1.69.0: openai==1.69.0 - openai-notiktoken-v1.102.0: openai==1.102.0 + openai-notiktoken-v1.103.0: openai==1.103.0 openai-notiktoken: pytest-asyncio openai-notiktoken-v1.0.1: httpx<0.28 openai-notiktoken-v1.35.15: httpx<0.28 @@ -540,6 +540,9 @@ deps = openai_agents-v0.1.0: openai-agents==0.1.0 openai_agents-v0.2.10: openai-agents==0.2.10 openai_agents: pytest-asyncio + openai_agents-v0.0.19: openai<1.103.0 + openai_agents-v0.1.0: openai<1.103.0 + openai_agents-v0.2.10: openai<1.103.0 huggingface_hub-v0.22.2: huggingface_hub==0.22.2 huggingface_hub-v0.26.5: huggingface_hub==0.26.5 From f702ec94badc17bd09d7d3ccf7414fde01a173c8 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Wed, 3 Sep 2025 09:44:55 +0200 Subject: [PATCH 08/26] fix: Constrain types of ai_track decorator (#4745) I followed how other functions in the SDK are typed. For example, other wrappers have the signature `(F) -> F` for a type variable `F`, although here the function can be async as well. Closes https://github.com/getsentry/sentry-python/issues/4663 --- sentry_sdk/ai/monitoring.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/sentry_sdk/ai/monitoring.py b/sentry_sdk/ai/monitoring.py index e3f372c3ba..9dd1aa132c 100644 --- a/sentry_sdk/ai/monitoring.py +++ b/sentry_sdk/ai/monitoring.py @@ -10,7 +10,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Optional, Callable, Any + from typing import Optional, Callable, Awaitable, Any, Union, TypeVar + + F = TypeVar("F", bound=Union[Callable[..., Any], Callable[..., Awaitable[Any]]]) _ai_pipeline_name = ContextVar("ai_pipeline_name", default=None) @@ -26,9 +28,9 @@ def get_ai_pipeline_name(): def ai_track(description, **span_kwargs): - # type: (str, Any) -> Callable[..., Any] + # type: (str, Any) -> Callable[[F], F] def decorator(f): - # type: (Callable[..., Any]) -> Callable[..., Any] + # type: (F) -> F def sync_wrapped(*args, **kwargs): # type: (Any, Any) -> Any curr_pipeline = _ai_pipeline_name.get() @@ -88,9 +90,9 @@ async def async_wrapped(*args, **kwargs): return res if inspect.iscoroutinefunction(f): - return wraps(f)(async_wrapped) + return wraps(f)(async_wrapped) # type: ignore else: - return wraps(f)(sync_wrapped) + return wraps(f)(sync_wrapped) # type: ignore return decorator From 5f2adcffecff85b1f736f93701cf154d58f85653 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Sep 2025 16:11:11 +0200 Subject: [PATCH 09/26] Wrap span restoration in `__exit__` in `capture_internal_exceptions` (#4719) Ref https://github.com/getsentry/sentry-python/issues/4718 Does not solve the underlying issue and might leave things in an inconsistent state, but it's still preferable to letting an error bubble up to the user. --- sentry_sdk/tracing.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index c9b357305a..0d1fcc45da 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -8,6 +8,7 @@ from sentry_sdk.consts import INSTRUMENTER, SPANSTATUS, SPANDATA, SPANTEMPLATE from sentry_sdk.profiler.continuous_profiler import get_profiler_id from sentry_sdk.utils import ( + capture_internal_exceptions, get_current_thread_meta, is_valid_sample_rate, logger, @@ -418,10 +419,11 @@ def __exit__(self, ty, value, tb): if value is not None and should_be_treated_as_error(ty, value): self.set_status(SPANSTATUS.INTERNAL_ERROR) - scope, old_span = self._context_manager_state - del self._context_manager_state - self.finish(scope) - scope.span = old_span + with capture_internal_exceptions(): + scope, old_span = self._context_manager_state + del self._context_manager_state + self.finish(scope) + scope.span = old_span @property def containing_transaction(self): From 6d6e8a2e70fee7553675288b82e28ba311ffca3c Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Sep 2025 16:13:51 +0200 Subject: [PATCH 10/26] Don't fail if there is no `_context_manager_state` (#4698) This is not a fix -- it just makes the SDK not propagate an internal SDK exception upwards. --- sentry_sdk/profiler/transaction_profiler.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sentry_sdk/profiler/transaction_profiler.py b/sentry_sdk/profiler/transaction_profiler.py index 3743b7c905..d228f77de9 100644 --- a/sentry_sdk/profiler/transaction_profiler.py +++ b/sentry_sdk/profiler/transaction_profiler.py @@ -45,6 +45,7 @@ ) from sentry_sdk.utils import ( capture_internal_exception, + capture_internal_exceptions, get_current_thread_meta, is_gevent, is_valid_sample_rate, @@ -369,12 +370,13 @@ def __enter__(self): def __exit__(self, ty, value, tb): # type: (Optional[Any], Optional[Any], Optional[Any]) -> None - self.stop() + with capture_internal_exceptions(): + self.stop() - scope, old_profile = self._context_manager_state - del self._context_manager_state + scope, old_profile = self._context_manager_state + del self._context_manager_state - scope.profile = old_profile + scope.profile = old_profile def write(self, ts, sample): # type: (int, ExtractedSample) -> None From a6e3b50004c13f32de8c30e7632c164a39d7babe Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Wed, 3 Sep 2025 16:49:29 +0200 Subject: [PATCH 11/26] feat(integrations): Add unraisable exception integration (#4733) Adds an uncaught exception integration, enabled by default. The integration forwards the exception to Sentry only if the exception value and stacktrace are set. Closes https://github.com/getsentry/sentry-python/issues/374 --- sentry_sdk/integrations/unraisablehook.py | 53 ++++++++++++++++++ .../unraisablehook/test_unraisablehook.py | 56 +++++++++++++++++++ tests/test_basics.py | 1 + 3 files changed, 110 insertions(+) create mode 100644 sentry_sdk/integrations/unraisablehook.py create mode 100644 tests/integrations/unraisablehook/test_unraisablehook.py diff --git a/sentry_sdk/integrations/unraisablehook.py b/sentry_sdk/integrations/unraisablehook.py new file mode 100644 index 0000000000..cfb8212c71 --- /dev/null +++ b/sentry_sdk/integrations/unraisablehook.py @@ -0,0 +1,53 @@ +import sys + +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) +from sentry_sdk.integrations import Integration + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + from typing import Any + + +class UnraisablehookIntegration(Integration): + identifier = "unraisablehook" + + @staticmethod + def setup_once(): + # type: () -> None + sys.unraisablehook = _make_unraisable(sys.unraisablehook) + + +def _make_unraisable(old_unraisablehook): + # type: (Callable[[sys.UnraisableHookArgs], Any]) -> Callable[[sys.UnraisableHookArgs], Any] + def sentry_sdk_unraisablehook(unraisable): + # type: (sys.UnraisableHookArgs) -> None + integration = sentry_sdk.get_client().get_integration(UnraisablehookIntegration) + + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_unraisablehook(unraisable) + + if unraisable.exc_value and unraisable.exc_traceback: + with capture_internal_exceptions(): + event, hint = event_from_exception( + ( + unraisable.exc_type, + unraisable.exc_value, + unraisable.exc_traceback, + ), + client_options=sentry_sdk.get_client().options, + mechanism={"type": "unraisablehook", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return old_unraisablehook(unraisable) + + return sentry_sdk_unraisablehook diff --git a/tests/integrations/unraisablehook/test_unraisablehook.py b/tests/integrations/unraisablehook/test_unraisablehook.py new file mode 100644 index 0000000000..2f97886ce8 --- /dev/null +++ b/tests/integrations/unraisablehook/test_unraisablehook.py @@ -0,0 +1,56 @@ +import pytest +import sys +import subprocess + +from textwrap import dedent + + +TEST_PARAMETERS = [ + ("", "HttpTransport"), + ('_experiments={"transport_http2": True}', "Http2Transport"), +] + +minimum_python_38 = pytest.mark.skipif( + sys.version_info < (3, 8), + reason="The unraisable exception hook is only available in Python 3.8 and above.", +) + + +@minimum_python_38 +@pytest.mark.parametrize("options, transport", TEST_PARAMETERS) +def test_unraisablehook(tmpdir, options, transport): + app = tmpdir.join("app.py") + app.write( + dedent( + """ + from sentry_sdk import init, transport + from sentry_sdk.integrations.unraisablehook import UnraisablehookIntegration + + class Undeletable: + def __del__(self): + 1 / 0 + + def capture_envelope(self, envelope): + print("capture_envelope was called") + event = envelope.get_event() + if event is not None: + print(event) + + transport.{transport}.capture_envelope = capture_envelope + + init("http://foobar@localhost/123", integrations=[UnraisablehookIntegration()], {options}) + + undeletable = Undeletable() + del undeletable + """.format( + transport=transport, options=options + ) + ) + ) + + output = subprocess.check_output( + [sys.executable, str(app)], stderr=subprocess.STDOUT + ) + + assert b"ZeroDivisionError" in output + assert b"capture_envelope was called" in output diff --git a/tests/test_basics.py b/tests/test_basics.py index 2eeba78216..45303c9a59 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -870,6 +870,7 @@ def foo(event, hint): (["celery"], "sentry.python"), (["dedupe"], "sentry.python"), (["excepthook"], "sentry.python"), + (["unraisablehook"], "sentry.python"), (["executing"], "sentry.python"), (["modules"], "sentry.python"), (["pure_eval"], "sentry.python"), From 4e845d5767f8fd43f7eee310afc269bc070c9b3f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Sep 2025 16:51:28 +0200 Subject: [PATCH 12/26] tests: Support dashes in test suite names (#4740) - actually support dashes in integration names in `tox.ini`/toxgen - make `split_tox_gh_actions.py` actually fail if it fails to parse `tox.ini` Context: `split_tox_gh_actions.py` was actually failing because it assumed there can't be dashes in test suite names, but since it was just printing the error instead of actually exiting with an error code, we didn't notice this in CI. --- .../split_tox_gh_actions.py | 41 ++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) 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 1c3435f43b..cf83e0a3fe 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -17,6 +17,7 @@ import configparser import hashlib +import re import sys from collections import defaultdict from functools import reduce @@ -25,6 +26,18 @@ from jinja2 import Environment, FileSystemLoader +TOXENV_REGEX = re.compile( + r""" + {?(?P(py\d+\.\d+,?)+)}? + -(?P[a-z](?:[a-z_]|-(?!v{?\d|latest))*[a-z0-9]) + (?:-( + (v{?(?P[0-9.]+[0-9a-z,.]*}?)) + | + (?Platest) + ))? +""", + re.VERBOSE, +) OUT_DIR = Path(__file__).resolve().parent.parent.parent / ".github" / "workflows" TOX_FILE = Path(__file__).resolve().parent.parent.parent / "tox.ini" @@ -202,29 +215,37 @@ def parse_tox(): py_versions_pinned = defaultdict(set) py_versions_latest = defaultdict(set) + parsed_correctly = True + for line in lines: # normalize lines line = line.strip().lower() try: # parse tox environment definition - try: - (raw_python_versions, framework, framework_versions) = line.split("-") - except ValueError: - (raw_python_versions, framework) = line.split("-") - framework_versions = [] + parsed = TOXENV_REGEX.match(line) + if not parsed: + print(f"ERROR reading line {line}") + raise ValueError("Failed to parse tox environment definition") + + groups = parsed.groupdict() + raw_python_versions = groups["py_versions"] + framework = groups["framework"] + framework_versions_latest = groups.get("framework_versions_latest") or False # collect python versions to test the framework in - raw_python_versions = set( - raw_python_versions.replace("{", "").replace("}", "").split(",") - ) - if "latest" in framework_versions: + raw_python_versions = set(raw_python_versions.split(",")) + if framework_versions_latest: py_versions_latest[framework] |= raw_python_versions else: py_versions_pinned[framework] |= raw_python_versions - except ValueError: + except Exception: print(f"ERROR reading line {line}") + parsed_correctly = False + + if not parsed_correctly: + raise RuntimeError("Failed to parse tox.ini") py_versions_pinned = _normalize_py_versions(py_versions_pinned) py_versions_latest = _normalize_py_versions(py_versions_latest) From 7bc91eda417db023b76bd4193c80288943a27a65 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Wed, 3 Sep 2025 17:10:33 +0200 Subject: [PATCH 13/26] tests: Move arq under toxgen (#4739) Remove hardcoded arq config, generate with toxgen instead. --- scripts/populate_tox/config.py | 7 +++ scripts/populate_tox/populate_tox.py | 1 - scripts/populate_tox/tox.jinja | 12 ---- tox.ini | 87 ++++++++++++++-------------- 4 files changed, 51 insertions(+), 56 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 69f7b02e21..f6093b0250 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -29,6 +29,13 @@ }, "python": ">=3.8", }, + "arq": { + "package": "arq", + "deps": { + "*": ["async-timeout", "pytest-asyncio", "fakeredis>=2.2.0,<2.8"], + "<=0.23": ["pydantic<2"], + }, + }, "bottle": { "package": "bottle", "deps": { diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 179a466944..a8c58938ae 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -67,7 +67,6 @@ "potel", # Integrations that can be migrated -- we should eventually remove all # of these from the IGNORE list - "arq", "asyncpg", "beam", "boto3", diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 42c570b111..115b99fd5c 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -36,10 +36,6 @@ envlist = # At a minimum, we should test against at least the lowest # and the latest supported version of a framework. - # Arq - {py3.7,py3.11}-arq-v{0.23} - {py3.7,py3.12,py3.13}-arq-latest - # Asgi {py3.7,py3.12,py3.13}-asgi @@ -164,14 +160,6 @@ deps = # === Integrations === - # Arq - arq-v0.23: arq~=0.23.0 - arq-v0.23: pydantic<2 - arq-latest: arq - arq: fakeredis>=2.2.0,<2.8 - arq: pytest-asyncio - arq: async-timeout - # Asgi asgi: pytest-asyncio asgi: async-asgi-testclient diff --git a/tox.ini b/tox.ini index c45c72bf85..f2ad720a25 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-02T14:49:13.002983+00:00 +# Last generated: 2025-09-03T15:01:21.035943+00:00 [tox] requires = @@ -36,10 +36,6 @@ envlist = # At a minimum, we should test against at least the lowest # and the latest supported version of a framework. - # Arq - {py3.7,py3.11}-arq-v{0.23} - {py3.7,py3.12,py3.13}-arq-latest - # Asgi {py3.7,py3.12,py3.13}-asgi @@ -123,9 +119,9 @@ envlist = # ~~~ AI ~~~ {py3.8,py3.11,py3.12}-anthropic-v0.16.0 - {py3.8,py3.11,py3.12}-anthropic-v0.32.0 - {py3.8,py3.11,py3.12}-anthropic-v0.48.0 - {py3.8,py3.12,py3.13}-anthropic-v0.64.0 + {py3.8,py3.11,py3.12}-anthropic-v0.33.1 + {py3.8,py3.11,py3.12}-anthropic-v0.50.0 + {py3.8,py3.12,py3.13}-anthropic-v0.66.0 {py3.9,py3.10,py3.11}-cohere-v5.4.0 {py3.9,py3.11,py3.12}-cohere-v5.9.4 @@ -141,14 +137,14 @@ envlist = {py3.9,py3.12,py3.13}-langchain-notiktoken-v0.3.27 {py3.8,py3.11,py3.12}-openai-base-v1.0.1 - {py3.8,py3.11,py3.12}-openai-base-v1.35.15 - {py3.8,py3.11,py3.12}-openai-base-v1.69.0 - {py3.8,py3.12,py3.13}-openai-base-v1.103.0 + {py3.8,py3.11,py3.12}-openai-base-v1.36.1 + {py3.8,py3.11,py3.12}-openai-base-v1.71.0 + {py3.8,py3.12,py3.13}-openai-base-v1.105.0 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 - {py3.8,py3.11,py3.12}-openai-notiktoken-v1.35.15 - {py3.8,py3.11,py3.12}-openai-notiktoken-v1.69.0 - {py3.8,py3.12,py3.13}-openai-notiktoken-v1.103.0 + {py3.8,py3.11,py3.12}-openai-notiktoken-v1.36.1 + {py3.8,py3.11,py3.12}-openai-notiktoken-v1.71.0 + {py3.8,py3.12,py3.13}-openai-notiktoken-v1.105.0 {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 @@ -227,6 +223,11 @@ envlist = # ~~~ Tasks ~~~ + {py3.7,py3.9,py3.10}-arq-v0.23 + {py3.7,py3.10,py3.11}-arq-v0.24.0 + {py3.7,py3.10,py3.11}-arq-v0.25.0 + {py3.8,py3.11,py3.12}-arq-v0.26.3 + {py3.6,py3.7,py3.8}-celery-v4.4.7 {py3.6,py3.7,py3.8}-celery-v5.0.5 {py3.8,py3.12,py3.13}-celery-v5.5.3 @@ -250,9 +251,9 @@ envlist = {py3.6,py3.7}-django-v1.11.29 {py3.6,py3.8,py3.9}-django-v2.2.28 {py3.6,py3.9,py3.10}-django-v3.2.25 - {py3.8,py3.11,py3.12}-django-v4.2.23 + {py3.8,py3.11,py3.12}-django-v4.2.24 {py3.10,py3.11,py3.12}-django-v5.0.14 - {py3.10,py3.12,py3.13}-django-v5.2.5 + {py3.10,py3.12,py3.13}-django-v5.2.6 {py3.6,py3.7,py3.8}-flask-v1.1.4 {py3.8,py3.12,py3.13}-flask-v2.3.3 @@ -353,14 +354,6 @@ deps = # === Integrations === - # Arq - arq-v0.23: arq~=0.23.0 - arq-v0.23: pydantic<2 - arq-latest: arq - arq: fakeredis>=2.2.0,<2.8 - arq: pytest-asyncio - arq: async-timeout - # Asgi asgi: pytest-asyncio asgi: async-asgi-testclient @@ -491,13 +484,12 @@ deps = # ~~~ AI ~~~ anthropic-v0.16.0: anthropic==0.16.0 - anthropic-v0.32.0: anthropic==0.32.0 - anthropic-v0.48.0: anthropic==0.48.0 - anthropic-v0.64.0: anthropic==0.64.0 + anthropic-v0.33.1: anthropic==0.33.1 + anthropic-v0.50.0: anthropic==0.50.0 + anthropic-v0.66.0: anthropic==0.66.0 anthropic: pytest-asyncio anthropic-v0.16.0: httpx<0.28.0 - anthropic-v0.32.0: httpx<0.28.0 - anthropic-v0.48.0: httpx<0.28.0 + anthropic-v0.33.1: httpx<0.28.0 cohere-v5.4.0: cohere==5.4.0 cohere-v5.9.4: cohere==5.9.4 @@ -520,21 +512,21 @@ deps = langchain-notiktoken-v0.3.27: langchain-community openai-base-v1.0.1: openai==1.0.1 - openai-base-v1.35.15: openai==1.35.15 - openai-base-v1.69.0: openai==1.69.0 - openai-base-v1.103.0: openai==1.103.0 + openai-base-v1.36.1: openai==1.36.1 + openai-base-v1.71.0: openai==1.71.0 + openai-base-v1.105.0: openai==1.105.0 openai-base: pytest-asyncio openai-base: tiktoken openai-base-v1.0.1: httpx<0.28 - openai-base-v1.35.15: httpx<0.28 + openai-base-v1.36.1: httpx<0.28 openai-notiktoken-v1.0.1: openai==1.0.1 - openai-notiktoken-v1.35.15: openai==1.35.15 - openai-notiktoken-v1.69.0: openai==1.69.0 - openai-notiktoken-v1.103.0: openai==1.103.0 + openai-notiktoken-v1.36.1: openai==1.36.1 + openai-notiktoken-v1.71.0: openai==1.71.0 + openai-notiktoken-v1.105.0: openai==1.105.0 openai-notiktoken: pytest-asyncio openai-notiktoken-v1.0.1: httpx<0.28 - openai-notiktoken-v1.35.15: httpx<0.28 + openai-notiktoken-v1.36.1: httpx<0.28 openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 @@ -635,6 +627,15 @@ deps = # ~~~ Tasks ~~~ + arq-v0.23: arq==0.23 + arq-v0.24.0: arq==0.24.0 + arq-v0.25.0: arq==0.25.0 + arq-v0.26.3: arq==0.26.3 + arq: async-timeout + arq: pytest-asyncio + arq: fakeredis>=2.2.0,<2.8 + arq-v0.23: pydantic<2 + celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 celery-v5.5.3: celery==5.5.3 @@ -661,23 +662,23 @@ deps = django-v1.11.29: django==1.11.29 django-v2.2.28: django==2.2.28 django-v3.2.25: django==3.2.25 - django-v4.2.23: django==4.2.23 + django-v4.2.24: django==4.2.24 django-v5.0.14: django==5.0.14 - django-v5.2.5: django==5.2.5 + django-v5.2.6: django==5.2.6 django: psycopg2-binary django: djangorestframework django: pytest-django django: Werkzeug django-v2.2.28: channels[daphne] django-v3.2.25: channels[daphne] - django-v4.2.23: channels[daphne] + django-v4.2.24: channels[daphne] django-v5.0.14: channels[daphne] - django-v5.2.5: channels[daphne] + django-v5.2.6: channels[daphne] django-v2.2.28: six django-v3.2.25: pytest-asyncio - django-v4.2.23: pytest-asyncio + django-v4.2.24: pytest-asyncio django-v5.0.14: pytest-asyncio - django-v5.2.5: pytest-asyncio + django-v5.2.6: pytest-asyncio django-v1.11.29: djangorestframework>=3.0,<4.0 django-v1.11.29: Werkzeug<2.1.0 django-v2.2.28: djangorestframework>=3.0,<4.0 From 6f396f490a0a2c9bee4f4f8e66db49cd1f738d81 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Sep 2025 09:24:28 +0200 Subject: [PATCH 14/26] meta: Update instructions on release process (#4755) --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 024a374f85..313910fe56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,18 +138,18 @@ _(only relevant for Python SDK core team)_ - On GitHub in the `sentry-python` repository, go to "Actions" and select the "Release" workflow. - Click on "Run workflow" on the right side, and make sure the `master` branch is selected. -- Set the "Version to release" input field. Here you decide if it is a major, minor or patch release. (See "Versioning Policy" below) +- Set the "Version to release" input field. Here you decide if it is a major, minor or patch release (see "Versioning Policy" below). - Click "Run Workflow". -This will trigger [Craft](https://github.com/getsentry/craft) to prepare everything needed for a release. (For more information, see [craft prepare](https://github.com/getsentry/craft#craft-prepare-preparing-a-new-release).) At the end of this process a release issue is created in the [Publish](https://github.com/getsentry/publish) repository. (Example release issue: https://github.com/getsentry/publish/issues/815) +This will trigger [Craft](https://github.com/getsentry/craft) to prepare everything needed for a release. (For more information, see [craft prepare](https://github.com/getsentry/craft#craft-prepare-preparing-a-new-release).) At the end of this process a release issue is created in the [Publish](https://github.com/getsentry/publish) repository (example issue: https://github.com/getsentry/publish/issues/815). -Now one of the persons with release privileges (most probably your engineering manager) will review this issue and then add the `accepted` label to the issue. +At the same time, the action will create a release branch in the `sentry-python` repository called `release/`. You may want to check out this branch and polish the auto-generated `CHANGELOG.md` before proceeding by including code snippets, descriptions, reordering and reformatting entries, in order to make the changelog as useful and actionable to users as possible. -There are always two persons involved in a release. +CI must be passing on the release branch; if there's any failure, Craft will not create a release. -If you are in a hurry and the release should be out immediately, there is a Slack channel called `#proj-release-approval` where you can see your release issue and where you can ping people to please have a look immediately. +Once the release branch is ready and green, notify your team (or your manager). They will need to add the `accepted` label to the issue in the `publish` repo. There are always two people involved in a release. Do not accept your own releases. -When the release issue is labeled `accepted`, [Craft](https://github.com/getsentry/craft) is triggered again to publish the release to all the right platforms. (See [craft publish](https://github.com/getsentry/craft#craft-publish-publishing-the-release) for more information.) At the end of this process the release issue on GitHub will be closed and the release is completed! Congratulations! +When the release issue is labeled `accepted`, [Craft](https://github.com/getsentry/craft) is triggered again to publish the release to all the right platforms. See [craft publish](https://github.com/getsentry/craft#craft-publish-publishing-the-release) for more information. At the end of this process, the release issue on GitHub will be closed and the release is completed! Congratulations! There is a sequence diagram visualizing all this in the [README.md](https://github.com/getsentry/publish) of the `Publish` repository. From c7f3b396920f652c9776baf4be7ebfedfbda7df7 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Thu, 4 Sep 2025 07:31:06 +0000 Subject: [PATCH 15/26] release: 2.36.0 --- CHANGELOG.md | 19 +++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f734976f..a0b3c1647e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 2.36.0 + +### Various fixes & improvements + +- meta: Update instructions on release process (#4755) by @sentrivana +- tests: Move arq under toxgen (#4739) by @sentrivana +- tests: Support dashes in test suite names (#4740) by @sentrivana +- feat(integrations): Add unraisable exception integration (#4733) by @alexander-alderman-webb +- Don't fail if there is no `_context_manager_state` (#4698) by @sentrivana +- Wrap span restoration in `__exit__` in `capture_internal_exceptions` (#4719) by @sentrivana +- fix: Constrain types of ai_track decorator (#4745) by @alexander-alderman-webb +- Fix `openai_agents` in CI (#4742) by @sentrivana +- Remove old langchain test suites from ignore list (#4737) by @sentrivana +- tests: Trigger Pytest failure when an unraisable exception occurs (#4738) by @alexander-alderman-webb +- fix(openai): Avoid double exit causing an unraisable exception (#4736) by @alexander-alderman-webb +- tests: Move langchain under toxgen (#4734) by @sentrivana +- toxgen: Add variants & move OpenAI under toxgen (#4730) by @sentrivana +- Update tox.ini (#4731) by @sentrivana + ## 2.35.2 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 0863980aac..835c20b112 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.35.2" +release = "2.36.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index d7a0603a10..3ed8efd506 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1329,4 +1329,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.35.2" +VERSION = "2.36.0" diff --git a/setup.py b/setup.py index ecb24290c8..828dd43461 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.35.2", + version="2.36.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From ea097ede7d8c90b65b6412250070033b66aa8283 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 4 Sep 2025 09:44:07 +0200 Subject: [PATCH 16/26] docs: Add snippet to configure sending unraisable exceptions to Sentry --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0b3c1647e..2c10ef8b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,24 @@ ### Various fixes & improvements +- **New integration:** Unraisable exceptions (#4733) by @alexander-alderman-webb + + Add the unraisable exception integration to your sentry_sdk.init call: +```python +import sentry_sdk +from sentry_sdk.integrations.unraisablehook import UnraisablehookIntegration + +sentry_sdk.init( + dsn="...", + integrations=[ + UnraisablehookIntegration(), + ] +) +``` + - meta: Update instructions on release process (#4755) by @sentrivana - tests: Move arq under toxgen (#4739) by @sentrivana - tests: Support dashes in test suite names (#4740) by @sentrivana -- feat(integrations): Add unraisable exception integration (#4733) by @alexander-alderman-webb - Don't fail if there is no `_context_manager_state` (#4698) by @sentrivana - Wrap span restoration in `__exit__` in `capture_internal_exceptions` (#4719) by @sentrivana - fix: Constrain types of ai_track decorator (#4745) by @alexander-alderman-webb From ff9b1c37f2ccbdad39e1950dab09546b29e2d247 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Sep 2025 10:23:49 +0200 Subject: [PATCH 17/26] tests: Remove openai pin and update tox (#4748) [v1.104.2](https://github.com/openai/openai-python/releases/tag/v1.104.2) of openai added back the missing export that was causing problems for `openai_agents`, so we can remove the version pin again. --- scripts/populate_tox/config.py | 1 - tox.ini | 17 +++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index f6093b0250..a2c4c8770c 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -188,7 +188,6 @@ "package": "openai-agents", "deps": { "*": ["pytest-asyncio"], - "<=0.2.10": ["openai<1.103.0"], }, "python": ">=3.10", }, diff --git a/tox.ini b/tox.ini index f2ad720a25..67ba6eadc6 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-03T15:01:21.035943+00:00 +# Last generated: 2025-09-04T07:00:53.509946+00:00 [tox] requires = @@ -148,7 +148,7 @@ envlist = {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 - {py3.10,py3.12,py3.13}-openai_agents-v0.2.10 + {py3.10,py3.12,py3.13}-openai_agents-v0.2.11 {py3.8,py3.10,py3.11}-huggingface_hub-v0.22.2 {py3.8,py3.11,py3.12}-huggingface_hub-v0.26.5 @@ -313,8 +313,8 @@ envlist = {py3.6}-trytond-v4.8.18 {py3.6,py3.7,py3.8}-trytond-v5.8.16 {py3.8,py3.10,py3.11}-trytond-v6.8.17 - {py3.8,py3.11,py3.12}-trytond-v7.0.34 - {py3.9,py3.12,py3.13}-trytond-v7.6.5 + {py3.8,py3.11,py3.12}-trytond-v7.0.35 + {py3.9,py3.12,py3.13}-trytond-v7.6.6 {py3.7,py3.12,py3.13}-typer-v0.15.4 {py3.7,py3.12,py3.13}-typer-v0.16.1 @@ -530,11 +530,8 @@ deps = openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 - openai_agents-v0.2.10: openai-agents==0.2.10 + openai_agents-v0.2.11: openai-agents==0.2.11 openai_agents: pytest-asyncio - openai_agents-v0.0.19: openai<1.103.0 - openai_agents-v0.1.0: openai<1.103.0 - openai_agents-v0.2.10: openai<1.103.0 huggingface_hub-v0.22.2: huggingface_hub==0.22.2 huggingface_hub-v0.26.5: huggingface_hub==0.26.5 @@ -790,8 +787,8 @@ deps = trytond-v4.8.18: trytond==4.8.18 trytond-v5.8.16: trytond==5.8.16 trytond-v6.8.17: trytond==6.8.17 - trytond-v7.0.34: trytond==7.0.34 - trytond-v7.6.5: trytond==7.6.5 + trytond-v7.0.35: trytond==7.0.35 + trytond-v7.6.6: trytond==7.6.6 trytond: werkzeug trytond-v4.6.22: werkzeug<1.0 trytond-v4.8.18: werkzeug<1.0 From c378c2d8d9032a50f4371df20a1929756342b245 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Sep 2025 13:45:07 +0200 Subject: [PATCH 18/26] tests: Move beam under toxgen (#4759) - move beam under toxgen - lower the waiting time between pypi requests Ref https://github.com/getsentry/sentry-python/issues/4506 --- .github/workflows/test-integrations-tasks.yml | 2 +- scripts/populate_tox/config.py | 4 ++++ scripts/populate_tox/populate_tox.py | 3 +-- scripts/populate_tox/tox.jinja | 8 -------- tox.ini | 20 ++++++++++--------- 5 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index a489f64410..f842683285 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.8","3.10","3.11","3.12","3.13"] + python-version: ["3.7","3.10","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index a2c4c8770c..689253e889 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -36,6 +36,10 @@ "<=0.23": ["pydantic<2"], }, }, + "beam": { + "package": "apache-beam", + "python": ">=3.7", + }, "bottle": { "package": "bottle", "deps": { diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index a8c58938ae..3d9ef23b66 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -40,7 +40,7 @@ lstrip_blocks=True, ) -PYPI_COOLDOWN = 0.15 # seconds to wait between requests to PyPI +PYPI_COOLDOWN = 0.1 # seconds to wait between requests to PyPI PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" @@ -68,7 +68,6 @@ # Integrations that can be migrated -- we should eventually remove all # of these from the IGNORE list "asyncpg", - "beam", "boto3", "chalice", "gcp", diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 115b99fd5c..65a5ba3f36 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -46,10 +46,6 @@ envlist = # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda - # Beam - {py3.7}-beam-v{2.12} - {py3.8,py3.11}-beam-latest - # Boto3 {py3.6,py3.7}-boto3-v{1.12} {py3.7,py3.11,py3.12}-boto3-v{1.23} @@ -177,10 +173,6 @@ deps = aws_lambda: requests aws_lambda: uvicorn - # Beam - beam-v2.12: apache-beam~=2.12.0 - beam-latest: apache-beam - # Boto3 boto3-v1.12: boto3~=1.12.0 boto3-v1.23: boto3~=1.23.0 diff --git a/tox.ini b/tox.ini index 67ba6eadc6..fd633654be 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-04T07:00:53.509946+00:00 +# Last generated: 2025-09-04T10:35:13.756355+00:00 [tox] requires = @@ -46,10 +46,6 @@ envlist = # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda - # Beam - {py3.7}-beam-v{2.12} - {py3.8,py3.11}-beam-latest - # Boto3 {py3.6,py3.7}-boto3-v{1.12} {py3.7,py3.11,py3.12}-boto3-v{1.23} @@ -228,6 +224,11 @@ envlist = {py3.7,py3.10,py3.11}-arq-v0.25.0 {py3.8,py3.11,py3.12}-arq-v0.26.3 + {py3.7}-beam-v2.14.0 + {py3.7,py3.8}-beam-v2.32.0 + {py3.8,py3.10,py3.11}-beam-v2.50.0 + {py3.9,py3.12,py3.13}-beam-v2.67.0 + {py3.6,py3.7,py3.8}-celery-v4.4.7 {py3.6,py3.7,py3.8}-celery-v5.0.5 {py3.8,py3.12,py3.13}-celery-v5.5.3 @@ -371,10 +372,6 @@ deps = aws_lambda: requests aws_lambda: uvicorn - # Beam - beam-v2.12: apache-beam~=2.12.0 - beam-latest: apache-beam - # Boto3 boto3-v1.12: boto3~=1.12.0 boto3-v1.23: boto3~=1.23.0 @@ -633,6 +630,11 @@ deps = arq: fakeredis>=2.2.0,<2.8 arq-v0.23: pydantic<2 + beam-v2.14.0: apache-beam==2.14.0 + beam-v2.32.0: apache-beam==2.32.0 + beam-v2.50.0: apache-beam==2.50.0 + beam-v2.67.0: apache-beam==2.67.0 + celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 celery-v5.5.3: celery==5.5.3 From 58a9827e1a5bb207a34651409a303bc21890fb66 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Thu, 4 Sep 2025 15:38:33 +0200 Subject: [PATCH 19/26] feat: Add LangGraph integration (#4727) - Add LangGraph integration - Compilation of StateGraphs results in an agent creation span (according to [OTEL semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-agent-spans/)) - Runtime executions are done on Pregel instances - we are wrapping their invoke & ainvoke which produces the invoke_agent spans - There's some internals that automatically switch between invoke & stream on CompiledStateGraph (which is a subclass of Pregel), which results in duplicate spans if both are instrumented. For now, only invoke is wrapped to prevent this duplication. - Agent handoffs in LangGraph are done via tools - so there is no real possibility to create handoff spans within the SDK. Looks like this will be handled in product logic instead. Closes TET-991 Closes PY-1799 --------- Co-authored-by: Anton Pirker --- .github/workflows/test-integrations-ai.yml | 4 + pyproject.toml | 4 + scripts/populate_tox/config.py | 3 + scripts/populate_tox/tox.jinja | 1 + .../split_tox_gh_actions.py | 1 + sentry_sdk/consts.py | 1 + sentry_sdk/integrations/__init__.py | 2 + sentry_sdk/integrations/langgraph.py | 321 +++++++++ setup.py | 1 + tests/integrations/langgraph/__init__.py | 3 + .../integrations/langgraph/test_langgraph.py | 632 ++++++++++++++++++ tox.ini | 9 +- 12 files changed, 981 insertions(+), 1 deletion(-) create mode 100644 sentry_sdk/integrations/langgraph.py create mode 100644 tests/integrations/langgraph/__init__.py create mode 100644 tests/integrations/langgraph/test_langgraph.py diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 72a4253744..26a8bdb8bb 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -74,6 +74,10 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openai-notiktoken" + - name: Test langgraph pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-langgraph" - name: Test openai_agents pinned run: | set -x # print commands that are executed diff --git a/pyproject.toml b/pyproject.toml index deba247e39..44eded7641 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,6 +130,10 @@ ignore_missing_imports = true module = "langchain.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "langgraph.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "executing.*" ignore_missing_imports = true diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 689253e889..6795e36303 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -157,6 +157,9 @@ }, "include": "<1.0", }, + "langgraph": { + "package": "langgraph", + }, "launchdarkly": { "package": "launchdarkly-server-sdk", }, diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 65a5ba3f36..241e0ca288 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -338,6 +338,7 @@ setenv = huggingface_hub: TESTPATH=tests/integrations/huggingface_hub langchain-base: TESTPATH=tests/integrations/langchain langchain-notiktoken: TESTPATH=tests/integrations/langchain + langgraph: TESTPATH=tests/integrations/langgraph launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru 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 cf83e0a3fe..51ee614d04 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -78,6 +78,7 @@ "langchain-notiktoken", "openai-base", "openai-notiktoken", + "langgraph", "openai_agents", "huggingface_hub", ], diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 3ed8efd506..5480ef5dce 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -792,6 +792,7 @@ class OP: FUNCTION_AWS = "function.aws" FUNCTION_GCP = "function.gcp" GEN_AI_CHAT = "gen_ai.chat" + GEN_AI_CREATE_AGENT = "gen_ai.create_agent" GEN_AI_EMBEDDINGS = "gen_ai.embeddings" GEN_AI_EXECUTE_TOOL = "gen_ai.execute_tool" GEN_AI_HANDOFF = "gen_ai.handoff" diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 6f0109aced..7f202221a7 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -95,6 +95,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.huey.HueyIntegration", "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", "sentry_sdk.integrations.langchain.LangchainIntegration", + "sentry_sdk.integrations.langgraph.LanggraphIntegration", "sentry_sdk.integrations.litestar.LitestarIntegration", "sentry_sdk.integrations.loguru.LoguruIntegration", "sentry_sdk.integrations.openai.OpenAIIntegration", @@ -142,6 +143,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "grpc": (1, 32, 0), # grpcio "huggingface_hub": (0, 22), "langchain": (0, 1, 0), + "langgraph": (0, 6, 6), "launchdarkly": (9, 8, 0), "loguru": (0, 7, 0), "openai": (1, 0, 0), diff --git a/sentry_sdk/integrations/langgraph.py b/sentry_sdk/integrations/langgraph.py new file mode 100644 index 0000000000..4b241fe895 --- /dev/null +++ b/sentry_sdk/integrations/langgraph.py @@ -0,0 +1,321 @@ +from functools import wraps +from typing import Any, Callable, List, Optional + +import sentry_sdk +from sentry_sdk.ai.utils import set_data_normalized +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.utils import safe_serialize + + +try: + from langgraph.graph import StateGraph + from langgraph.pregel import Pregel +except ImportError: + raise DidNotEnable("langgraph not installed") + + +class LanggraphIntegration(Integration): + identifier = "langgraph" + origin = f"auto.ai.{identifier}" + + def __init__(self, include_prompts=True): + # type: (LanggraphIntegration, bool) -> None + self.include_prompts = include_prompts + + @staticmethod + def setup_once(): + # type: () -> None + # LangGraph lets users create agents using a StateGraph or the Functional API. + # StateGraphs are then compiled to a CompiledStateGraph. Both CompiledStateGraph and + # the functional API execute on a Pregel instance. Pregel is the runtime for the graph + # and the invocation happens on Pregel, so patching the invoke methods takes care of both. + # The streaming methods are not patched, because due to some internal reasons, LangGraph + # will automatically patch the streaming methods to run through invoke, and by doing this + # we prevent duplicate spans for invocations. + StateGraph.compile = _wrap_state_graph_compile(StateGraph.compile) + if hasattr(Pregel, "invoke"): + Pregel.invoke = _wrap_pregel_invoke(Pregel.invoke) + if hasattr(Pregel, "ainvoke"): + Pregel.ainvoke = _wrap_pregel_ainvoke(Pregel.ainvoke) + + +def _get_graph_name(graph_obj): + # type: (Any) -> Optional[str] + for attr in ["name", "graph_name", "__name__", "_name"]: + if hasattr(graph_obj, attr): + name = getattr(graph_obj, attr) + if name and isinstance(name, str): + return name + return None + + +def _normalize_langgraph_message(message): + # type: (Any) -> Any + if not hasattr(message, "content"): + return None + + parsed = {"role": getattr(message, "type", None), "content": message.content} + + for attr in ["name", "tool_calls", "function_call", "tool_call_id"]: + if hasattr(message, attr): + value = getattr(message, attr) + if value is not None: + parsed[attr] = value + + return parsed + + +def _parse_langgraph_messages(state): + # type: (Any) -> Optional[List[Any]] + if not state: + return None + + messages = None + + if isinstance(state, dict): + messages = state.get("messages") + elif hasattr(state, "messages"): + messages = state.messages + elif hasattr(state, "get") and callable(state.get): + try: + messages = state.get("messages") + except Exception: + pass + + if not messages or not isinstance(messages, (list, tuple)): + return None + + normalized_messages = [] + for message in messages: + try: + normalized = _normalize_langgraph_message(message) + if normalized: + normalized_messages.append(normalized) + except Exception: + continue + + return normalized_messages if normalized_messages else None + + +def _wrap_state_graph_compile(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + @wraps(f) + def new_compile(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return f(self, *args, **kwargs) + with sentry_sdk.start_span( + op=OP.GEN_AI_CREATE_AGENT, + origin=LanggraphIntegration.origin, + ) as span: + compiled_graph = f(self, *args, **kwargs) + + compiled_graph_name = getattr(compiled_graph, "name", None) + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "create_agent") + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, compiled_graph_name) + + if compiled_graph_name: + span.description = f"create_agent {compiled_graph_name}" + else: + span.description = "create_agent" + + if kwargs.get("model", None) is not None: + span.set_data(SPANDATA.GEN_AI_REQUEST_MODEL, kwargs.get("model")) + + tools = None + get_graph = getattr(compiled_graph, "get_graph", None) + if get_graph and callable(get_graph): + graph_obj = compiled_graph.get_graph() + nodes = getattr(graph_obj, "nodes", None) + if nodes and isinstance(nodes, dict): + tools_node = nodes.get("tools") + if tools_node: + data = getattr(tools_node, "data", None) + if data and hasattr(data, "tools_by_name"): + tools = list(data.tools_by_name.keys()) + + if tools is not None: + span.set_data(SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools) + + return compiled_graph + + return new_compile + + +def _wrap_pregel_invoke(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + + @wraps(f) + def new_invoke(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return f(self, *args, **kwargs) + + graph_name = _get_graph_name(self) + span_name = ( + f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent" + ) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name=span_name, + origin=LanggraphIntegration.origin, + ) as span: + if graph_name: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + # Store input messages to later compare with output + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + safe_serialize(input_messages), + ) + + result = f(self, *args, **kwargs) + + _set_response_attributes(span, input_messages, result, integration) + + return result + + return new_invoke + + +def _wrap_pregel_ainvoke(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + + @wraps(f) + async def new_ainvoke(self, *args, **kwargs): + # type: (Any, Any, Any) -> Any + integration = sentry_sdk.get_client().get_integration(LanggraphIntegration) + if integration is None: + return await f(self, *args, **kwargs) + + graph_name = _get_graph_name(self) + span_name = ( + f"invoke_agent {graph_name}".strip() if graph_name else "invoke_agent" + ) + + with sentry_sdk.start_span( + op=OP.GEN_AI_INVOKE_AGENT, + name=span_name, + origin=LanggraphIntegration.origin, + ) as span: + if graph_name: + span.set_data(SPANDATA.GEN_AI_PIPELINE_NAME, graph_name) + span.set_data(SPANDATA.GEN_AI_AGENT_NAME, graph_name) + + span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") + + input_messages = None + if ( + len(args) > 0 + and should_send_default_pii() + and integration.include_prompts + ): + input_messages = _parse_langgraph_messages(args[0]) + if input_messages: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_MESSAGES, + safe_serialize(input_messages), + ) + + result = await f(self, *args, **kwargs) + + _set_response_attributes(span, input_messages, result, integration) + + return result + + return new_ainvoke + + +def _get_new_messages(input_messages, output_messages): + # type: (Optional[List[Any]], Optional[List[Any]]) -> Optional[List[Any]] + """Extract only the new messages added during this invocation.""" + if not output_messages: + return None + + if not input_messages: + return output_messages + + # only return the new messages, aka the output messages that are not in the input messages + input_count = len(input_messages) + new_messages = ( + output_messages[input_count:] if len(output_messages) > input_count else [] + ) + + return new_messages if new_messages else None + + +def _extract_llm_response_text(messages): + # type: (Optional[List[Any]]) -> Optional[str] + if not messages: + return None + + for message in reversed(messages): + if isinstance(message, dict): + role = message.get("role") + if role in ["assistant", "ai"]: + content = message.get("content") + if content and isinstance(content, str): + return content + + return None + + +def _extract_tool_calls(messages): + # type: (Optional[List[Any]]) -> Optional[List[Any]] + if not messages: + return None + + tool_calls = [] + for message in messages: + if isinstance(message, dict): + msg_tool_calls = message.get("tool_calls") + if msg_tool_calls and isinstance(msg_tool_calls, list): + tool_calls.extend(msg_tool_calls) + + return tool_calls if tool_calls else None + + +def _set_response_attributes(span, input_messages, result, integration): + # type: (Any, Optional[List[Any]], Any, LanggraphIntegration) -> None + if not (should_send_default_pii() and integration.include_prompts): + return + + parsed_response_messages = _parse_langgraph_messages(result) + new_messages = _get_new_messages(input_messages, parsed_response_messages) + + llm_response_text = _extract_llm_response_text(new_messages) + if llm_response_text: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text) + elif new_messages: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(new_messages) + ) + else: + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(result)) + + tool_calls = _extract_tool_calls(new_messages) + if tool_calls: + set_data_normalized( + span, + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + safe_serialize(tool_calls), + unpack=False, + ) diff --git a/setup.py b/setup.py index 828dd43461..ca6e7ec534 100644 --- a/setup.py +++ b/setup.py @@ -63,6 +63,7 @@ def get_file_text(file_name): "huey": ["huey>=2"], "huggingface_hub": ["huggingface_hub>=0.22"], "langchain": ["langchain>=0.0.210"], + "langgraph": ["langgraph>=0.6.6"], "launchdarkly": ["launchdarkly-server-sdk>=9.8.0"], "litestar": ["litestar>=2.0.0"], "loguru": ["loguru>=0.5"], diff --git a/tests/integrations/langgraph/__init__.py b/tests/integrations/langgraph/__init__.py new file mode 100644 index 0000000000..b7dd1cb562 --- /dev/null +++ b/tests/integrations/langgraph/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("langgraph") diff --git a/tests/integrations/langgraph/test_langgraph.py b/tests/integrations/langgraph/test_langgraph.py new file mode 100644 index 0000000000..5e35f772f5 --- /dev/null +++ b/tests/integrations/langgraph/test_langgraph.py @@ -0,0 +1,632 @@ +import asyncio +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from sentry_sdk import start_transaction +from sentry_sdk.consts import SPANDATA, OP + + +def mock_langgraph_imports(): + """Mock langgraph modules to prevent import errors.""" + mock_state_graph = MagicMock() + mock_pregel = MagicMock() + + langgraph_graph_mock = MagicMock() + langgraph_graph_mock.StateGraph = mock_state_graph + + langgraph_pregel_mock = MagicMock() + langgraph_pregel_mock.Pregel = mock_pregel + + sys.modules["langgraph"] = MagicMock() + sys.modules["langgraph.graph"] = langgraph_graph_mock + sys.modules["langgraph.pregel"] = langgraph_pregel_mock + + return mock_state_graph, mock_pregel + + +mock_state_graph, mock_pregel = mock_langgraph_imports() + +from sentry_sdk.integrations.langgraph import ( # noqa: E402 + LanggraphIntegration, + _parse_langgraph_messages, + _wrap_state_graph_compile, + _wrap_pregel_invoke, + _wrap_pregel_ainvoke, +) + + +class MockStateGraph: + def __init__(self, schema=None): + self.name = "test_graph" + self.schema = schema + self._compiled_graph = None + + def compile(self, *args, **kwargs): + compiled = MockCompiledGraph(self.name) + compiled.graph = self + return compiled + + +class MockCompiledGraph: + def __init__(self, name="test_graph"): + self.name = name + self._graph = None + + def get_graph(self): + return MockGraphRepresentation() + + def invoke(self, state, config=None): + return {"messages": [MockMessage("Response from graph")]} + + async def ainvoke(self, state, config=None): + return {"messages": [MockMessage("Async response from graph")]} + + +class MockGraphRepresentation: + def __init__(self): + self.nodes = {"tools": MockToolsNode()} + + +class MockToolsNode: + def __init__(self): + self.data = MockToolsData() + + +class MockToolsData: + def __init__(self): + self.tools_by_name = { + "search_tool": MockTool("search_tool"), + "calculator": MockTool("calculator"), + } + + +class MockTool: + def __init__(self, name): + self.name = name + + +class MockMessage: + def __init__( + self, + content, + name=None, + tool_calls=None, + function_call=None, + role=None, + type=None, + ): + self.content = content + self.name = name + self.tool_calls = tool_calls + self.function_call = function_call + self.role = role + # The integration uses getattr(message, "type", None) for the role in _normalize_langgraph_message + # Set default type based on name if type not explicitly provided + if type is None and name in ["assistant", "ai", "user", "system", "function"]: + self.type = name + else: + self.type = type + + +class MockPregelInstance: + def __init__(self, name="test_pregel"): + self.name = name + self.graph_name = name + + def invoke(self, state, config=None): + return {"messages": [MockMessage("Pregel response")]} + + async def ainvoke(self, state, config=None): + return {"messages": [MockMessage("Async Pregel response")]} + + +def test_langgraph_integration_init(): + """Test LanggraphIntegration initialization with different parameters.""" + integration = LanggraphIntegration() + assert integration.include_prompts is True + assert integration.identifier == "langgraph" + assert integration.origin == "auto.ai.langgraph" + + integration = LanggraphIntegration(include_prompts=False) + assert integration.include_prompts is False + assert integration.identifier == "langgraph" + assert integration.origin == "auto.ai.langgraph" + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +def test_state_graph_compile( + sentry_init, capture_events, send_default_pii, include_prompts +): + """Test StateGraph.compile() wrapper creates proper create_agent span.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + graph = MockStateGraph() + + def original_compile(self, *args, **kwargs): + return MockCompiledGraph(self.name) + + with patch("sentry_sdk.integrations.langgraph.StateGraph"): + with start_transaction(): + wrapped_compile = _wrap_state_graph_compile(original_compile) + compiled_graph = wrapped_compile( + graph, model="test-model", checkpointer=None + ) + + assert compiled_graph is not None + assert compiled_graph.name == "test_graph" + + tx = events[0] + assert tx["type"] == "transaction" + + agent_spans = [span for span in tx["spans"] if span["op"] == OP.GEN_AI_CREATE_AGENT] + assert len(agent_spans) == 1 + + agent_span = agent_spans[0] + assert agent_span["description"] == "create_agent test_graph" + assert agent_span["origin"] == "auto.ai.langgraph" + assert agent_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "create_agent" + assert agent_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "test_graph" + assert agent_span["data"][SPANDATA.GEN_AI_REQUEST_MODEL] == "test-model" + assert SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS in agent_span["data"] + + tools_data = agent_span["data"][SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS] + assert tools_data == ["search_tool", "calculator"] + assert len(tools_data) == 2 + assert "search_tool" in tools_data + assert "calculator" in tools_data + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +def test_pregel_invoke(sentry_init, capture_events, send_default_pii, include_prompts): + """Test Pregel.invoke() wrapper creates proper invoke_agent span.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + test_state = { + "messages": [ + MockMessage("Hello, can you help me?", name="user"), + MockMessage("Of course! How can I assist you?", name="assistant"), + ] + } + + pregel = MockPregelInstance("test_graph") + + expected_assistant_response = "I'll help you with that task!" + expected_tool_calls = [ + { + "id": "call_test_123", + "type": "function", + "function": {"name": "search_tool", "arguments": '{"query": "help"}'}, + } + ] + + def original_invoke(self, *args, **kwargs): + input_messages = args[0].get("messages", []) + new_messages = input_messages + [ + MockMessage( + content=expected_assistant_response, + name="assistant", + tool_calls=expected_tool_calls, + ) + ] + return {"messages": new_messages} + + with start_transaction(): + wrapped_invoke = _wrap_pregel_invoke(original_invoke) + result = wrapped_invoke(pregel, test_state) + + assert result is not None + + tx = events[0] + assert tx["type"] == "transaction" + + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + assert invoke_span["description"] == "invoke_agent test_graph" + assert invoke_span["origin"] == "auto.ai.langgraph" + assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent" + assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == "test_graph" + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "test_graph" + + if send_default_pii and include_prompts: + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"] + assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"] + + request_messages = invoke_span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] + + if isinstance(request_messages, str): + import json + + request_messages = json.loads(request_messages) + assert len(request_messages) == 2 + assert request_messages[0]["content"] == "Hello, can you help me?" + assert request_messages[1]["content"] == "Of course! How can I assist you?" + + response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + assert response_text == expected_assistant_response + + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"] + tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + if isinstance(tool_calls_data, str): + import json + + tool_calls_data = json.loads(tool_calls_data) + + assert len(tool_calls_data) == 1 + assert tool_calls_data[0]["id"] == "call_test_123" + assert tool_calls_data[0]["function"]["name"] == "search_tool" + else: + assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span.get("data", {}) + assert SPANDATA.GEN_AI_RESPONSE_TEXT not in invoke_span.get("data", {}) + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in invoke_span.get("data", {}) + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +def test_pregel_ainvoke(sentry_init, capture_events, send_default_pii, include_prompts): + """Test Pregel.ainvoke() async wrapper creates proper invoke_agent span.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + test_state = {"messages": [MockMessage("What's the weather like?", name="user")]} + pregel = MockPregelInstance("async_graph") + + expected_assistant_response = "It's sunny and 72°F today!" + expected_tool_calls = [ + { + "id": "call_weather_456", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"location": "current"}'}, + } + ] + + async def original_ainvoke(self, *args, **kwargs): + input_messages = args[0].get("messages", []) + new_messages = input_messages + [ + MockMessage( + content=expected_assistant_response, + name="assistant", + tool_calls=expected_tool_calls, + ) + ] + return {"messages": new_messages} + + async def run_test(): + with start_transaction(): + + wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke) + result = await wrapped_ainvoke(pregel, test_state) + return result + + result = asyncio.run(run_test()) + assert result is not None + + tx = events[0] + assert tx["type"] == "transaction" + + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + assert invoke_span["description"] == "invoke_agent async_graph" + assert invoke_span["origin"] == "auto.ai.langgraph" + assert invoke_span["data"][SPANDATA.GEN_AI_OPERATION_NAME] == "invoke_agent" + assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == "async_graph" + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == "async_graph" + + if send_default_pii and include_prompts: + assert SPANDATA.GEN_AI_REQUEST_MESSAGES in invoke_span["data"] + assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"] + + response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + assert response_text == expected_assistant_response + + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"] + tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + if isinstance(tool_calls_data, str): + import json + + tool_calls_data = json.loads(tool_calls_data) + + assert len(tool_calls_data) == 1 + assert tool_calls_data[0]["id"] == "call_weather_456" + assert tool_calls_data[0]["function"]["name"] == "get_weather" + else: + assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in invoke_span.get("data", {}) + assert SPANDATA.GEN_AI_RESPONSE_TEXT not in invoke_span.get("data", {}) + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in invoke_span.get("data", {}) + + +def test_pregel_invoke_error(sentry_init, capture_events): + """Test error handling during graph execution.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + test_state = {"messages": [MockMessage("This will fail")]} + pregel = MockPregelInstance("error_graph") + + def original_invoke(self, *args, **kwargs): + raise Exception("Graph execution failed") + + with start_transaction(), pytest.raises(Exception, match="Graph execution failed"): + + wrapped_invoke = _wrap_pregel_invoke(original_invoke) + wrapped_invoke(pregel, test_state) + + tx = events[0] + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + assert invoke_span.get("tags", {}).get("status") == "internal_error" + + +def test_pregel_ainvoke_error(sentry_init, capture_events): + """Test error handling during async graph execution.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + test_state = {"messages": [MockMessage("This will fail async")]} + pregel = MockPregelInstance("async_error_graph") + + async def original_ainvoke(self, *args, **kwargs): + raise Exception("Async graph execution failed") + + async def run_error_test(): + with start_transaction(), pytest.raises( + Exception, match="Async graph execution failed" + ): + + wrapped_ainvoke = _wrap_pregel_ainvoke(original_ainvoke) + await wrapped_ainvoke(pregel, test_state) + + asyncio.run(run_error_test()) + + tx = events[0] + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + assert invoke_span.get("tags", {}).get("status") == "internal_error" + + +def test_span_origin(sentry_init, capture_events): + """Test that span origins are correctly set.""" + sentry_init( + integrations=[LanggraphIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + graph = MockStateGraph() + + def original_compile(self, *args, **kwargs): + return MockCompiledGraph(self.name) + + with start_transaction(): + from sentry_sdk.integrations.langgraph import _wrap_state_graph_compile + + wrapped_compile = _wrap_state_graph_compile(original_compile) + wrapped_compile(graph) + + tx = events[0] + assert tx["contexts"]["trace"]["origin"] == "manual" + + for span in tx["spans"]: + assert span["origin"] == "auto.ai.langgraph" + + +@pytest.mark.parametrize("graph_name", ["my_graph", None, ""]) +def test_pregel_invoke_with_different_graph_names( + sentry_init, capture_events, graph_name +): + """Test Pregel.invoke() with different graph name scenarios.""" + sentry_init( + integrations=[LanggraphIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + pregel = MockPregelInstance(graph_name) if graph_name else MockPregelInstance() + if not graph_name: + + delattr(pregel, "name") + delattr(pregel, "graph_name") + + def original_invoke(self, *args, **kwargs): + return {"result": "test"} + + with start_transaction(): + + wrapped_invoke = _wrap_pregel_invoke(original_invoke) + wrapped_invoke(pregel, {"messages": []}) + + tx = events[0] + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + + if graph_name and graph_name.strip(): + assert invoke_span["description"] == "invoke_agent my_graph" + assert invoke_span["data"][SPANDATA.GEN_AI_PIPELINE_NAME] == graph_name + assert invoke_span["data"][SPANDATA.GEN_AI_AGENT_NAME] == graph_name + else: + assert invoke_span["description"] == "invoke_agent" + assert SPANDATA.GEN_AI_PIPELINE_NAME not in invoke_span.get("data", {}) + assert SPANDATA.GEN_AI_AGENT_NAME not in invoke_span.get("data", {}) + + +def test_complex_message_parsing(): + """Test message parsing with complex message structures.""" + messages = [ + MockMessage(content="User query", name="user"), + MockMessage( + content="Assistant response with tools", + name="assistant", + tool_calls=[ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + }, + { + "id": "call_2", + "type": "function", + "function": {"name": "calculate", "arguments": '{"x": 5}'}, + }, + ], + ), + MockMessage( + content="Function call response", + name="function", + function_call={"name": "search", "arguments": '{"query": "test"}'}, + ), + ] + + state = {"messages": messages} + result = _parse_langgraph_messages(state) + + assert result is not None + assert len(result) == 3 + + assert result[0]["content"] == "User query" + assert result[0]["name"] == "user" + assert "tool_calls" not in result[0] + assert "function_call" not in result[0] + + assert result[1]["content"] == "Assistant response with tools" + assert result[1]["name"] == "assistant" + assert len(result[1]["tool_calls"]) == 2 + + assert result[2]["content"] == "Function call response" + assert result[2]["name"] == "function" + assert result[2]["function_call"]["name"] == "search" + + +def test_extraction_functions_complex_scenario(sentry_init, capture_events): + """Test extraction functions with complex scenarios including multiple messages and edge cases.""" + sentry_init( + integrations=[LanggraphIntegration(include_prompts=True)], + traces_sample_rate=1.0, + send_default_pii=True, + ) + events = capture_events() + + pregel = MockPregelInstance("complex_graph") + test_state = {"messages": [MockMessage("Complex request", name="user")]} + + def original_invoke(self, *args, **kwargs): + input_messages = args[0].get("messages", []) + new_messages = input_messages + [ + MockMessage( + content="I'll help with multiple tasks", + name="assistant", + tool_calls=[ + { + "id": "call_multi_1", + "type": "function", + "function": { + "name": "search", + "arguments": '{"query": "complex"}', + }, + }, + { + "id": "call_multi_2", + "type": "function", + "function": { + "name": "calculate", + "arguments": '{"expr": "2+2"}', + }, + }, + ], + ), + MockMessage("", name="assistant"), + MockMessage("Final response", name="ai", type="ai"), + ] + return {"messages": new_messages} + + with start_transaction(): + wrapped_invoke = _wrap_pregel_invoke(original_invoke) + result = wrapped_invoke(pregel, test_state) + + assert result is not None + + tx = events[0] + invoke_spans = [ + span for span in tx["spans"] if span["op"] == OP.GEN_AI_INVOKE_AGENT + ] + assert len(invoke_spans) == 1 + + invoke_span = invoke_spans[0] + assert SPANDATA.GEN_AI_RESPONSE_TEXT in invoke_span["data"] + response_text = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] + assert response_text == "Final response" + + assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS in invoke_span["data"] + import json + + tool_calls_data = invoke_span["data"][SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS] + if isinstance(tool_calls_data, str): + tool_calls_data = json.loads(tool_calls_data) + + assert len(tool_calls_data) == 2 + assert tool_calls_data[0]["id"] == "call_multi_1" + assert tool_calls_data[0]["function"]["name"] == "search" + assert tool_calls_data[1]["id"] == "call_multi_2" + assert tool_calls_data[1]["function"]["name"] == "calculate" diff --git a/tox.ini b/tox.ini index fd633654be..40afc2a6a7 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-04T10:35:13.756355+00:00 +# Last generated: 2025-09-04T12:59:44.328902+00:00 [tox] requires = @@ -142,6 +142,9 @@ envlist = {py3.8,py3.11,py3.12}-openai-notiktoken-v1.71.0 {py3.8,py3.12,py3.13}-openai-notiktoken-v1.105.0 + {py3.9,py3.12,py3.13}-langgraph-v0.6.6 + {py3.10,py3.12,py3.13}-langgraph-v1.0.0a2 + {py3.10,py3.11,py3.12}-openai_agents-v0.0.19 {py3.10,py3.12,py3.13}-openai_agents-v0.1.0 {py3.10,py3.12,py3.13}-openai_agents-v0.2.11 @@ -525,6 +528,9 @@ deps = openai-notiktoken-v1.0.1: httpx<0.28 openai-notiktoken-v1.36.1: httpx<0.28 + langgraph-v0.6.6: langgraph==0.6.6 + langgraph-v1.0.0a2: langgraph==1.0.0a2 + openai_agents-v0.0.19: openai-agents==0.0.19 openai_agents-v0.1.0: openai-agents==0.1.0 openai_agents-v0.2.11: openai-agents==0.2.11 @@ -841,6 +847,7 @@ setenv = huggingface_hub: TESTPATH=tests/integrations/huggingface_hub langchain-base: TESTPATH=tests/integrations/langchain langchain-notiktoken: TESTPATH=tests/integrations/langchain + langgraph: TESTPATH=tests/integrations/langgraph launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru From 9711b3be884263bb34f1ae5a0f719cb9acb4b0ca Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 4 Sep 2025 16:08:26 +0200 Subject: [PATCH 20/26] tests: Move asyncpg under toxgen (#4757) - remove hardcoded asyncpg config, let toxgen take care of generating it - isolate DB so that multiple asyncpg test suites for different envs can run at the same time without touching the same DB - update instructions on running the test suite locally Ref https://github.com/getsentry/sentry-python/issues/4506 --- .github/workflows/test-integrations-dbs.yml | 2 +- scripts/populate_tox/config.py | 7 ++++ scripts/populate_tox/populate_tox.py | 1 - scripts/populate_tox/tox.jinja | 9 ----- tests/integrations/asyncpg/test_asyncpg.py | 38 ++++++++++++++++----- tox.ini | 30 ++++++++-------- 6 files changed, 53 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index 5fc0be029b..2d6af43bc3 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.8","3.11","3.12","3.13"] + python-version: ["3.7","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 6795e36303..1dbc78ccf0 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -36,6 +36,13 @@ "<=0.23": ["pydantic<2"], }, }, + "asyncpg": { + "package": "asyncpg", + "deps": { + "*": ["pytest-asyncio"], + }, + "python": ">=3.7", + }, "beam": { "package": "apache-beam", "python": ">=3.7", diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 3d9ef23b66..076a8358f7 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -67,7 +67,6 @@ "potel", # Integrations that can be migrated -- we should eventually remove all # of these from the IGNORE list - "asyncpg", "boto3", "chalice", "gcp", diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 241e0ca288..0ad9af8321 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -39,10 +39,6 @@ envlist = # Asgi {py3.7,py3.12,py3.13}-asgi - # asyncpg - {py3.7,py3.10}-asyncpg-v{0.23} - {py3.8,py3.11,py3.12}-asyncpg-latest - # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda @@ -160,11 +156,6 @@ deps = asgi: pytest-asyncio asgi: async-asgi-testclient - # Asyncpg - asyncpg-v0.23: asyncpg~=0.23.0 - asyncpg-latest: asyncpg - asyncpg: pytest-asyncio - # AWS Lambda aws_lambda: aws-cdk-lib aws_lambda: aws-sam-cli diff --git a/tests/integrations/asyncpg/test_asyncpg.py b/tests/integrations/asyncpg/test_asyncpg.py index e36d15c5d2..e23612c055 100644 --- a/tests/integrations/asyncpg/test_asyncpg.py +++ b/tests/integrations/asyncpg/test_asyncpg.py @@ -3,21 +3,13 @@ Tests need a local postgresql instance running, this can best be done using ```sh -docker run --rm --name some-postgres -e POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -d -p 5432:5432 postgres +docker run --rm --name some-postgres -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=sentry -d -p 5432:5432 postgres ``` The tests use the following credentials to establish a database connection. """ import os - - -PG_HOST = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost") -PG_PORT = int(os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432")) -PG_USER = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres") -PG_PASSWORD = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry") -PG_NAME = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_NAME", "postgres") - import datetime from contextlib import contextmanager from unittest import mock @@ -33,6 +25,19 @@ from sentry_sdk.tracing_utils import record_sql_queries from tests.conftest import ApproxDict +PG_HOST = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_HOST", "localhost") +PG_PORT = int(os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PORT", "5432")) +PG_USER = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_USER", "postgres") +PG_PASSWORD = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_PASSWORD", "sentry") +PG_NAME_BASE = os.getenv("SENTRY_PYTHON_TEST_POSTGRES_NAME", "postgres") + + +def _get_db_name(): + pid = os.getpid() + return f"{PG_NAME_BASE}_{pid}" + + +PG_NAME = _get_db_name() PG_CONNECTION_URI = "postgresql://{}:{}@{}/{}".format( PG_USER, PG_PASSWORD, PG_HOST, PG_NAME @@ -55,6 +60,21 @@ @pytest_asyncio.fixture(autouse=True) async def _clean_pg(): + # Create the test database if it doesn't exist + default_conn = await connect( + "postgresql://{}:{}@{}".format(PG_USER, PG_PASSWORD, PG_HOST) + ) + try: + # Check if database exists, create if not + result = await default_conn.fetchval( + "SELECT 1 FROM pg_database WHERE datname = $1", PG_NAME + ) + if not result: + await default_conn.execute(f'CREATE DATABASE "{PG_NAME}"') + finally: + await default_conn.close() + + # Now connect to our test database and set up the table conn = await connect(PG_CONNECTION_URI) await conn.execute("DROP TABLE IF EXISTS users") await conn.execute( diff --git a/tox.ini b/tox.ini index 40afc2a6a7..1627cf2458 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-04T12:59:44.328902+00:00 +# Last generated: 2025-09-04T13:56:54.117272+00:00 [tox] requires = @@ -39,10 +39,6 @@ envlist = # Asgi {py3.7,py3.12,py3.13}-asgi - # asyncpg - {py3.7,py3.10}-asyncpg-v{0.23} - {py3.8,py3.11,py3.12}-asyncpg-latest - # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda @@ -135,12 +131,12 @@ envlist = {py3.8,py3.11,py3.12}-openai-base-v1.0.1 {py3.8,py3.11,py3.12}-openai-base-v1.36.1 {py3.8,py3.11,py3.12}-openai-base-v1.71.0 - {py3.8,py3.12,py3.13}-openai-base-v1.105.0 + {py3.8,py3.12,py3.13}-openai-base-v1.106.0 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.36.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.71.0 - {py3.8,py3.12,py3.13}-openai-notiktoken-v1.105.0 + {py3.8,py3.12,py3.13}-openai-notiktoken-v1.106.0 {py3.9,py3.12,py3.13}-langgraph-v0.6.6 {py3.10,py3.12,py3.13}-langgraph-v1.0.0a2 @@ -157,6 +153,11 @@ envlist = # ~~~ DBs ~~~ + {py3.7,py3.8,py3.9}-asyncpg-v0.23.0 + {py3.7,py3.9,py3.10}-asyncpg-v0.25.0 + {py3.7,py3.9,py3.10}-asyncpg-v0.27.0 + {py3.8,py3.11,py3.12}-asyncpg-v0.30.0 + {py3.7,py3.11,py3.12}-clickhouse_driver-v0.2.9 {py3.6}-pymongo-v3.5.1 @@ -362,11 +363,6 @@ deps = asgi: pytest-asyncio asgi: async-asgi-testclient - # Asyncpg - asyncpg-v0.23: asyncpg~=0.23.0 - asyncpg-latest: asyncpg - asyncpg: pytest-asyncio - # AWS Lambda aws_lambda: aws-cdk-lib aws_lambda: aws-sam-cli @@ -514,7 +510,7 @@ deps = openai-base-v1.0.1: openai==1.0.1 openai-base-v1.36.1: openai==1.36.1 openai-base-v1.71.0: openai==1.71.0 - openai-base-v1.105.0: openai==1.105.0 + openai-base-v1.106.0: openai==1.106.0 openai-base: pytest-asyncio openai-base: tiktoken openai-base-v1.0.1: httpx<0.28 @@ -523,7 +519,7 @@ deps = openai-notiktoken-v1.0.1: openai==1.0.1 openai-notiktoken-v1.36.1: openai==1.36.1 openai-notiktoken-v1.71.0: openai==1.71.0 - openai-notiktoken-v1.105.0: openai==1.105.0 + openai-notiktoken-v1.106.0: openai==1.106.0 openai-notiktoken: pytest-asyncio openai-notiktoken-v1.0.1: httpx<0.28 openai-notiktoken-v1.36.1: httpx<0.28 @@ -544,6 +540,12 @@ deps = # ~~~ DBs ~~~ + asyncpg-v0.23.0: asyncpg==0.23.0 + asyncpg-v0.25.0: asyncpg==0.25.0 + asyncpg-v0.27.0: asyncpg==0.27.0 + asyncpg-v0.30.0: asyncpg==0.30.0 + asyncpg: pytest-asyncio + clickhouse_driver-v0.2.9: clickhouse-driver==0.2.9 pymongo-v3.5.1: pymongo==3.5.1 From b50f7e4a68c69caccdf29bc6b645f51c215e7ada Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Fri, 5 Sep 2025 08:46:08 +0200 Subject: [PATCH 21/26] Format span attributes in AI integrations (#4762) The AI Agents integrations render stringified json-like data in a nice way (make the sub nodes of the data structure collapsible) In Javascript it comes down to having double quotes in a string: - Good: `'{"role": "system", "content": "some context"}'` - Bad: `"{'role': 'system', 'content': 'some context'}"` Also pydantics `model_dump()` sometimes returns `function` or `class` objects that can not be json serialized so I updated `_normalize_data()` to make sure everything is converted to a primitive data type, always. --- sentry_sdk/ai/utils.py | 15 +++++++++------ tests/integrations/cohere/test_cohere.py | 8 ++++---- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index cf52cba6e8..2dc0de4ef3 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -1,30 +1,33 @@ +import json + from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any + from sentry_sdk.tracing import Span -from sentry_sdk.tracing import Span from sentry_sdk.utils import logger def _normalize_data(data, unpack=True): # type: (Any, bool) -> Any - # convert pydantic data (e.g. OpenAI v1+) to json compatible format if hasattr(data, "model_dump"): try: - return data.model_dump() + return _normalize_data(data.model_dump(), unpack=unpack) except Exception as e: logger.warning("Could not convert pydantic data to JSON: %s", e) - return data + return data if isinstance(data, (int, float, bool, str)) else str(data) + if isinstance(data, list): if unpack and len(data) == 1: return _normalize_data(data[0], unpack=unpack) # remove empty dimensions return list(_normalize_data(x, unpack=unpack) for x in data) + if isinstance(data, dict): return {k: _normalize_data(v, unpack=unpack) for (k, v) in data.items()} - return data + return data if isinstance(data, (int, float, bool, str)) else str(data) def set_data_normalized(span, key, value, unpack=True): @@ -33,4 +36,4 @@ def set_data_normalized(span, key, value, unpack=True): if isinstance(normalized, (int, float, bool, str)): span.set_data(key, normalized) else: - span.set_data(key, str(normalized)) + span.set_data(key, json.dumps(normalized)) diff --git a/tests/integrations/cohere/test_cohere.py b/tests/integrations/cohere/test_cohere.py index b8b6067625..ee876172d1 100644 --- a/tests/integrations/cohere/test_cohere.py +++ b/tests/integrations/cohere/test_cohere.py @@ -58,11 +58,11 @@ def test_nonstreaming_chat( if send_default_pii and include_prompts: assert ( - "{'role': 'system', 'content': 'some context'}" + '{"role": "system", "content": "some context"}' in span["data"][SPANDATA.AI_INPUT_MESSAGES] ) assert ( - "{'role': 'user', 'content': 'hello'}" + '{"role": "user", "content": "hello"}' in span["data"][SPANDATA.AI_INPUT_MESSAGES] ) assert "the model response" in span["data"][SPANDATA.AI_RESPONSES] @@ -135,11 +135,11 @@ def test_streaming_chat(sentry_init, capture_events, send_default_pii, include_p if send_default_pii and include_prompts: assert ( - "{'role': 'system', 'content': 'some context'}" + '{"role": "system", "content": "some context"}' in span["data"][SPANDATA.AI_INPUT_MESSAGES] ) assert ( - "{'role': 'user', 'content': 'hello'}" + '{"role": "user", "content": "hello"}' in span["data"][SPANDATA.AI_INPUT_MESSAGES] ) assert "the model response" in span["data"][SPANDATA.AI_RESPONSES] From 0c0a8d8497647e40ae8b285f5a53069394b084ad Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 5 Sep 2025 09:06:19 +0200 Subject: [PATCH 22/26] ci: Fix celery (#4765) We have some newrelic interference/compatibility tests that were failing with the newest newrelic release. Looking at that release, newrelic completely [rehauled](https://github.com/newrelic/newrelic-python-agent/commit/3cfce55a51ec0cf81919ebd475765707d39c90e0) their celery instrumentation, so I'm pinning our tests to only test against older newrelic versions where we had the problem in the first place. Rerunning toxgen on the updated config also pulled in a new openai release. --- scripts/populate_tox/config.py | 2 +- tox.ini | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 1dbc78ccf0..921098e7e6 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -56,7 +56,7 @@ "celery": { "package": "celery", "deps": { - "*": ["newrelic", "redis"], + "*": ["newrelic<10.17.0", "redis"], "py3.7": ["importlib-metadata<5.0"], }, }, diff --git a/tox.ini b/tox.ini index 1627cf2458..994ad22314 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-04T13:56:54.117272+00:00 +# Last generated: 2025-09-05T06:53:57.545461+00:00 [tox] requires = @@ -131,12 +131,12 @@ envlist = {py3.8,py3.11,py3.12}-openai-base-v1.0.1 {py3.8,py3.11,py3.12}-openai-base-v1.36.1 {py3.8,py3.11,py3.12}-openai-base-v1.71.0 - {py3.8,py3.12,py3.13}-openai-base-v1.106.0 + {py3.8,py3.12,py3.13}-openai-base-v1.106.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.0.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.36.1 {py3.8,py3.11,py3.12}-openai-notiktoken-v1.71.0 - {py3.8,py3.12,py3.13}-openai-notiktoken-v1.106.0 + {py3.8,py3.12,py3.13}-openai-notiktoken-v1.106.1 {py3.9,py3.12,py3.13}-langgraph-v0.6.6 {py3.10,py3.12,py3.13}-langgraph-v1.0.0a2 @@ -510,7 +510,7 @@ deps = openai-base-v1.0.1: openai==1.0.1 openai-base-v1.36.1: openai==1.36.1 openai-base-v1.71.0: openai==1.71.0 - openai-base-v1.106.0: openai==1.106.0 + openai-base-v1.106.1: openai==1.106.1 openai-base: pytest-asyncio openai-base: tiktoken openai-base-v1.0.1: httpx<0.28 @@ -519,7 +519,7 @@ deps = openai-notiktoken-v1.0.1: openai==1.0.1 openai-notiktoken-v1.36.1: openai==1.36.1 openai-notiktoken-v1.71.0: openai==1.71.0 - openai-notiktoken-v1.106.0: openai==1.106.0 + openai-notiktoken-v1.106.1: openai==1.106.1 openai-notiktoken: pytest-asyncio openai-notiktoken-v1.0.1: httpx<0.28 openai-notiktoken-v1.36.1: httpx<0.28 @@ -646,7 +646,7 @@ deps = celery-v4.4.7: celery==4.4.7 celery-v5.0.5: celery==5.0.5 celery-v5.5.3: celery==5.5.3 - celery: newrelic + celery: newrelic<10.17.0 celery: redis py3.7-celery: importlib-metadata<5.0 From ad3c435398d78949eda68dff66ef8eb8b4928679 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 5 Sep 2025 09:44:32 +0200 Subject: [PATCH 23/26] tests: Move boto3 tests under toxgen (#4761) Move boto3 under toxgen. Also, update how Python version constraints are generated. Ref https://github.com/getsentry/sentry-python/issues/4506 --- .github/workflows/test-integrations-cloud.yml | 4 +- scripts/populate_tox/config.py | 6 +++ scripts/populate_tox/populate_tox.py | 3 +- scripts/populate_tox/tox.jinja | 12 ------ tox.ini | 39 ++++++++++--------- 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index a04d57497a..8688a1d48e 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -29,7 +29,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8","3.11","3.12","3.13"] + python-version: ["3.8","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 @@ -108,7 +108,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.9","3.11","3.12","3.13"] + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 diff --git a/scripts/populate_tox/config.py b/scripts/populate_tox/config.py index 921098e7e6..5aba82b11b 100644 --- a/scripts/populate_tox/config.py +++ b/scripts/populate_tox/config.py @@ -47,6 +47,12 @@ "package": "apache-beam", "python": ">=3.7", }, + "boto3": { + "package": "boto3", + "deps": { + "py3.7,py3.8": ["urllib3<2.0.0"], + }, + }, "bottle": { "package": "bottle", "deps": { diff --git a/scripts/populate_tox/populate_tox.py b/scripts/populate_tox/populate_tox.py index 076a8358f7..b8cc988fda 100644 --- a/scripts/populate_tox/populate_tox.py +++ b/scripts/populate_tox/populate_tox.py @@ -67,7 +67,6 @@ "potel", # Integrations that can be migrated -- we should eventually remove all # of these from the IGNORE list - "boto3", "chalice", "gcp", "httpx", @@ -439,7 +438,7 @@ def _render_dependencies(integration: str, releases: list[Version]) -> list[str] rendered.append(f"{integration}: {dep}") elif constraint.startswith("py3"): for dep in deps: - rendered.append(f"{constraint}-{integration}: {dep}") + rendered.append(f"{{{constraint}}}-{integration}: {dep}") else: restriction = SpecifierSet(constraint) for release in releases: diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 0ad9af8321..7f23d1fbc7 100755 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -42,12 +42,6 @@ envlist = # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda - # Boto3 - {py3.6,py3.7}-boto3-v{1.12} - {py3.7,py3.11,py3.12}-boto3-v{1.23} - {py3.11,py3.12}-boto3-v{1.34} - {py3.11,py3.12,py3.13}-boto3-latest - # Chalice {py3.6,py3.9}-chalice-v{1.16} {py3.8,py3.12,py3.13}-chalice-latest @@ -164,12 +158,6 @@ deps = aws_lambda: requests aws_lambda: uvicorn - # Boto3 - boto3-v1.12: boto3~=1.12.0 - boto3-v1.23: boto3~=1.23.0 - boto3-v1.34: boto3~=1.34.0 - boto3-latest: boto3 - # Chalice chalice: pytest-chalice==0.0.5 chalice-v1.16: chalice~=1.16.0 diff --git a/tox.ini b/tox.ini index 994ad22314..948887f1dd 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ # The file (and all resulting CI YAMLs) then need to be regenerated via # "scripts/generate-test-files.sh". # -# Last generated: 2025-09-05T06:53:57.545461+00:00 +# Last generated: 2025-09-05T07:14:50.663886+00:00 [tox] requires = @@ -42,12 +42,6 @@ envlist = # AWS Lambda {py3.8,py3.9,py3.11,py3.13}-aws_lambda - # Boto3 - {py3.6,py3.7}-boto3-v{1.12} - {py3.7,py3.11,py3.12}-boto3-v{1.23} - {py3.11,py3.12}-boto3-v{1.34} - {py3.11,py3.12,py3.13}-boto3-latest - # Chalice {py3.6,py3.9}-chalice-v{1.16} {py3.8,py3.12,py3.13}-chalice-latest @@ -152,6 +146,13 @@ envlist = {py3.8,py3.12,py3.13}-huggingface_hub-v0.35.0rc0 + # ~~~ Cloud ~~~ + {py3.6,py3.7}-boto3-v1.12.49 + {py3.6,py3.9,py3.10}-boto3-v1.20.54 + {py3.7,py3.11,py3.12}-boto3-v1.28.85 + {py3.9,py3.12,py3.13}-boto3-v1.40.24 + + # ~~~ DBs ~~~ {py3.7,py3.8,py3.9}-asyncpg-v0.23.0 {py3.7,py3.9,py3.10}-asyncpg-v0.25.0 @@ -371,12 +372,6 @@ deps = aws_lambda: requests aws_lambda: uvicorn - # Boto3 - boto3-v1.12: boto3~=1.12.0 - boto3-v1.23: boto3~=1.23.0 - boto3-v1.34: boto3~=1.34.0 - boto3-latest: boto3 - # Chalice chalice: pytest-chalice==0.0.5 chalice-v1.16: chalice~=1.16.0 @@ -539,6 +534,14 @@ deps = huggingface_hub-v0.35.0rc0: huggingface_hub==0.35.0rc0 + # ~~~ Cloud ~~~ + boto3-v1.12.49: boto3==1.12.49 + boto3-v1.20.54: boto3==1.20.54 + boto3-v1.28.85: boto3==1.28.85 + boto3-v1.40.24: boto3==1.40.24 + {py3.7,py3.8}-boto3: urllib3<2.0.0 + + # ~~~ DBs ~~~ asyncpg-v0.23.0: asyncpg==0.23.0 asyncpg-v0.25.0: asyncpg==0.25.0 @@ -604,7 +607,7 @@ deps = graphene: fastapi graphene: flask graphene: httpx - py3.6-graphene: aiocontextvars + {py3.6}-graphene: aiocontextvars strawberry-v0.209.8: strawberry-graphql[fastapi,flask]==0.209.8 strawberry-v0.233.3: strawberry-graphql[fastapi,flask]==0.233.3 @@ -648,7 +651,7 @@ deps = celery-v5.5.3: celery==5.5.3 celery: newrelic<10.17.0 celery: redis - py3.7-celery: importlib-metadata<5.0 + {py3.7}-celery: importlib-metadata<5.0 dramatiq-v1.9.0: dramatiq==1.9.0 dramatiq-v1.12.3: dramatiq==1.12.3 @@ -717,7 +720,7 @@ deps = starlette-v0.16.0: httpx<0.28.0 starlette-v0.26.1: httpx<0.28.0 starlette-v0.36.3: httpx<0.28.0 - py3.6-starlette: aiocontextvars + {py3.6}-starlette: aiocontextvars fastapi-v0.79.1: fastapi==0.79.1 fastapi-v0.91.0: fastapi==0.91.0 @@ -731,7 +734,7 @@ deps = fastapi-v0.79.1: httpx<0.28.0 fastapi-v0.91.0: httpx<0.28.0 fastapi-v0.103.2: httpx<0.28.0 - py3.6-fastapi: aiocontextvars + {py3.6}-fastapi: aiocontextvars # ~~~ Web 2 ~~~ @@ -787,7 +790,7 @@ deps = tornado: pytest tornado-v6.0.4: pytest<8.2 tornado-v6.2: pytest<8.2 - py3.6-tornado: aiocontextvars + {py3.6}-tornado: aiocontextvars # ~~~ Misc ~~~ From dee6de1579ba37acb46af622e2892d862e9c70ef Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Fri, 5 Sep 2025 13:13:16 +0200 Subject: [PATCH 24/26] feat(agents): improve instrumentation of input messages (#4750) - Improve the instrumentation of input messages in the AI agents instrumentations. Before: Screenshot 2025-09-03 at 16 30 05 After: Screenshot 2025-09-03 at 16 30 08 Closes TET-1058 --------- Co-authored-by: Anton Pirker --- sentry_sdk/integrations/langchain.py | 138 +++++++++++++----- sentry_sdk/integrations/langgraph.py | 12 +- sentry_sdk/integrations/openai.py | 43 ++++-- .../integrations/openai_agents/utils.py | 9 +- .../integrations/langchain/test_langchain.py | 73 +++++++++ tests/integrations/openai/test_openai.py | 16 +- .../openai_agents/test_openai_agents.py | 5 +- 7 files changed, 234 insertions(+), 62 deletions(-) diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py index 7e04a740ed..a53115a2a9 100644 --- a/sentry_sdk/integrations/langchain.py +++ b/sentry_sdk/integrations/langchain.py @@ -51,7 +51,6 @@ "presence_penalty": SPANDATA.GEN_AI_REQUEST_PRESENCE_PENALTY, "temperature": SPANDATA.GEN_AI_REQUEST_TEMPERATURE, "tool_calls": SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, - "tools": SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, "top_k": SPANDATA.GEN_AI_REQUEST_TOP_K, "top_p": SPANDATA.GEN_AI_REQUEST_TOP_P, } @@ -203,8 +202,12 @@ def on_llm_start( if key in all_params and all_params[key] is not None: set_data_normalized(span, attribute, all_params[key], unpack=False) + _set_tools_on_span(span, all_params.get("tools")) + if should_send_default_pii() and self.include_prompts: - set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompts) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompts, unpack=False + ) def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs): # type: (SentryLangchainCallback, Dict[str, Any], List[List[BaseMessage]], UUID, Any) -> Any @@ -246,14 +249,20 @@ def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs): if key in all_params and all_params[key] is not None: set_data_normalized(span, attribute, all_params[key], unpack=False) + _set_tools_on_span(span, all_params.get("tools")) + if should_send_default_pii() and self.include_prompts: + normalized_messages = [] + for list_ in messages: + for message in list_: + normalized_messages.append( + self._normalize_langchain_message(message) + ) set_data_normalized( span, SPANDATA.GEN_AI_REQUEST_MESSAGES, - [ - [self._normalize_langchain_message(x) for x in list_] - for list_ in messages - ], + normalized_messages, + unpack=False, ) def on_chat_model_end(self, response, *, run_id, **kwargs): @@ -351,9 +360,7 @@ def on_agent_finish(self, finish, *, run_id, **kwargs): if should_send_default_pii() and self.include_prompts: set_data_normalized( - span, - SPANDATA.GEN_AI_RESPONSE_TEXT, - finish.return_values.items(), + span, SPANDATA.GEN_AI_RESPONSE_TEXT, finish.return_values.items() ) self._exit_span(span_data, run_id) @@ -473,13 +480,11 @@ def _get_token_usage(obj): if usage is not None: return usage - # check for usage in the object itself for name in possible_names: usage = _get_value(obj, name) if usage is not None: return usage - # no usage found anywhere return None @@ -531,6 +536,87 @@ def _get_request_data(obj, args, kwargs): return (agent_name, tools) +def _simplify_langchain_tools(tools): + # type: (Any) -> Optional[List[Any]] + """Parse and simplify tools into a cleaner format.""" + if not tools: + return None + + if not isinstance(tools, (list, tuple)): + return None + + simplified_tools = [] + for tool in tools: + try: + if isinstance(tool, dict): + + if "function" in tool and isinstance(tool["function"], dict): + func = tool["function"] + simplified_tool = { + "name": func.get("name"), + "description": func.get("description"), + } + if simplified_tool["name"]: + simplified_tools.append(simplified_tool) + elif "name" in tool: + simplified_tool = { + "name": tool.get("name"), + "description": tool.get("description"), + } + simplified_tools.append(simplified_tool) + else: + name = ( + tool.get("name") + or tool.get("tool_name") + or tool.get("function_name") + ) + if name: + simplified_tools.append( + { + "name": name, + "description": tool.get("description") + or tool.get("desc"), + } + ) + elif hasattr(tool, "name"): + simplified_tool = { + "name": getattr(tool, "name", None), + "description": getattr(tool, "description", None) + or getattr(tool, "desc", None), + } + if simplified_tool["name"]: + simplified_tools.append(simplified_tool) + elif hasattr(tool, "__name__"): + simplified_tools.append( + { + "name": tool.__name__, + "description": getattr(tool, "__doc__", None), + } + ) + else: + tool_str = str(tool) + if tool_str and tool_str != "": + simplified_tools.append({"name": tool_str, "description": None}) + except Exception: + continue + + return simplified_tools if simplified_tools else None + + +def _set_tools_on_span(span, tools): + # type: (Span, Any) -> None + """Set available tools data on a span if tools are provided.""" + if tools is not None: + simplified_tools = _simplify_langchain_tools(tools) + if simplified_tools: + set_data_normalized( + span, + SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, + simplified_tools, + unpack=False, + ) + + def _wrap_configure(f): # type: (Callable[..., Any]) -> Callable[..., Any] @@ -601,7 +687,7 @@ def new_configure( ] elif isinstance(local_callbacks, BaseCallbackHandler): local_callbacks = [local_callbacks, sentry_handler] - else: # local_callbacks is a list + else: local_callbacks = [*local_callbacks, sentry_handler] return f( @@ -638,10 +724,7 @@ def new_invoke(self, *args, **kwargs): span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, False) - if tools: - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools, unpack=False - ) + _set_tools_on_span(span, tools) # Run the agent result = f(self, *args, **kwargs) @@ -653,11 +736,7 @@ def new_invoke(self, *args, **kwargs): and integration.include_prompts ): set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - [ - input, - ], + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, [input], unpack=False ) output = result.get("output") @@ -666,7 +745,7 @@ def new_invoke(self, *args, **kwargs): and should_send_default_pii() and integration.include_prompts ): - span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, output) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) return result @@ -698,10 +777,7 @@ def new_stream(self, *args, **kwargs): span.set_data(SPANDATA.GEN_AI_OPERATION_NAME, "invoke_agent") span.set_data(SPANDATA.GEN_AI_RESPONSE_STREAMING, True) - if tools: - set_data_normalized( - span, SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS, tools, unpack=False - ) + _set_tools_on_span(span, tools) input = args[0].get("input") if len(args) >= 1 else None if ( @@ -710,11 +786,7 @@ def new_stream(self, *args, **kwargs): and integration.include_prompts ): set_data_normalized( - span, - SPANDATA.GEN_AI_REQUEST_MESSAGES, - [ - input, - ], + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, [input], unpack=False ) # Run the agent @@ -737,7 +809,7 @@ def new_iterator(): and should_send_default_pii() and integration.include_prompts ): - span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, output) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) span.__exit__(None, None, None) @@ -756,7 +828,7 @@ async def new_iterator_async(): and should_send_default_pii() and integration.include_prompts ): - span.set_data(SPANDATA.GEN_AI_RESPONSE_TEXT, output) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, output) span.__exit__(None, None, None) diff --git a/sentry_sdk/integrations/langgraph.py b/sentry_sdk/integrations/langgraph.py index 4b241fe895..df3941bb13 100644 --- a/sentry_sdk/integrations/langgraph.py +++ b/sentry_sdk/integrations/langgraph.py @@ -183,7 +183,8 @@ def new_invoke(self, *args, **kwargs): set_data_normalized( span, SPANDATA.GEN_AI_REQUEST_MESSAGES, - safe_serialize(input_messages), + input_messages, + unpack=False, ) result = f(self, *args, **kwargs) @@ -232,7 +233,8 @@ async def new_ainvoke(self, *args, **kwargs): set_data_normalized( span, SPANDATA.GEN_AI_REQUEST_MESSAGES, - safe_serialize(input_messages), + input_messages, + unpack=False, ) result = await f(self, *args, **kwargs) @@ -305,11 +307,9 @@ def _set_response_attributes(span, input_messages, result, integration): if llm_response_text: set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, llm_response_text) elif new_messages: - set_data_normalized( - span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(new_messages) - ) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, new_messages) else: - set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(result)) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, result) tool_calls = _extract_tool_calls(new_messages) if tool_calls: diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index 6ea545322c..467116c8f4 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -179,7 +179,9 @@ def _set_input_data(span, kwargs, operation, integration): and should_send_default_pii() and integration.include_prompts ): - set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages, unpack=False + ) # Input attributes: Common set_data_normalized(span, SPANDATA.GEN_AI_SYSTEM, "openai") @@ -227,25 +229,46 @@ def _set_output_data(span, response, kwargs, integration, finish_span=True): if should_send_default_pii() and integration.include_prompts: response_text = [choice.message.dict() for choice in response.choices] if len(response_text) > 0: - set_data_normalized( - span, - SPANDATA.GEN_AI_RESPONSE_TEXT, - safe_serialize(response_text), - ) + set_data_normalized(span, SPANDATA.GEN_AI_RESPONSE_TEXT, response_text) + _calculate_token_usage(messages, response, span, None, integration.count_tokens) + if finish_span: span.__exit__(None, None, None) elif hasattr(response, "output"): if should_send_default_pii() and integration.include_prompts: - response_text = [item.to_dict() for item in response.output] - if len(response_text) > 0: + output_messages = { + "response": [], + "tool": [], + } # type: (dict[str, list[Any]]) + + for output in response.output: + if output.type == "function_call": + output_messages["tool"].append(output.dict()) + elif output.type == "message": + for output_message in output.content: + try: + output_messages["response"].append(output_message.text) + except AttributeError: + # Unknown output message type, just return the json + output_messages["response"].append(output_message.dict()) + + if len(output_messages["tool"]) > 0: set_data_normalized( span, - SPANDATA.GEN_AI_RESPONSE_TEXT, - safe_serialize(response_text), + SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, + output_messages["tool"], + unpack=False, + ) + + if len(output_messages["response"]) > 0: + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"] ) + _calculate_token_usage(messages, response, span, None, integration.count_tokens) + if finish_span: span.__exit__(None, None, None) diff --git a/sentry_sdk/integrations/openai_agents/utils.py b/sentry_sdk/integrations/openai_agents/utils.py index 1525346726..44b260d4bc 100644 --- a/sentry_sdk/integrations/openai_agents/utils.py +++ b/sentry_sdk/integrations/openai_agents/utils.py @@ -1,4 +1,5 @@ import sentry_sdk +from sentry_sdk.ai.utils import set_data_normalized from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii @@ -127,7 +128,9 @@ def _set_input_data(span, get_response_kwargs): if len(messages) > 0: request_messages.append({"role": role, "content": messages}) - span.set_data(SPANDATA.GEN_AI_REQUEST_MESSAGES, safe_serialize(request_messages)) + set_data_normalized( + span, SPANDATA.GEN_AI_REQUEST_MESSAGES, request_messages, unpack=False + ) def _set_output_data(span, result): @@ -157,6 +160,6 @@ def _set_output_data(span, result): ) if len(output_messages["response"]) > 0: - span.set_data( - SPANDATA.GEN_AI_RESPONSE_TEXT, safe_serialize(output_messages["response"]) + set_data_normalized( + span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"] ) diff --git a/tests/integrations/langchain/test_langchain.py b/tests/integrations/langchain/test_langchain.py index 9a06ac05d4..99dc5f4e37 100644 --- a/tests/integrations/langchain/test_langchain.py +++ b/tests/integrations/langchain/test_langchain.py @@ -589,3 +589,76 @@ def test_langchain_callback_list_existing_callback(sentry_init): [handler] = passed_callbacks assert handler is sentry_callback + + +def test_tools_integration_in_spans(sentry_init, capture_events): + """Test that tools are properly set on spans in actual LangChain integration.""" + global llm_type + llm_type = "openai-chat" + + sentry_init( + integrations=[LangchainIntegration(include_prompts=False)], + traces_sample_rate=1.0, + ) + events = capture_events() + + prompt = ChatPromptTemplate.from_messages( + [ + ("system", "You are a helpful assistant"), + ("user", "{input}"), + MessagesPlaceholder(variable_name="agent_scratchpad"), + ] + ) + + global stream_result_mock + stream_result_mock = Mock( + side_effect=[ + [ + ChatGenerationChunk( + type="ChatGenerationChunk", + message=AIMessageChunk(content="Simple response"), + ), + ] + ] + ) + + llm = MockOpenAI( + model_name="gpt-3.5-turbo", + temperature=0, + openai_api_key="badkey", + ) + agent = create_openai_tools_agent(llm, [get_word_length], prompt) + agent_executor = AgentExecutor(agent=agent, tools=[get_word_length], verbose=True) + + with start_transaction(): + list(agent_executor.stream({"input": "Hello"})) + + # Check that events were captured and contain tools data + if events: + tx = events[0] + spans = tx.get("spans", []) + + # Look for spans that should have tools data + tools_found = False + for span in spans: + span_data = span.get("data", {}) + if SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS in span_data: + tools_found = True + tools_data = span_data[SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS] + # Verify tools are in the expected format + assert isinstance(tools_data, (str, list)) # Could be serialized + if isinstance(tools_data, str): + # If serialized as string, should contain tool name + assert "get_word_length" in tools_data + else: + # If still a list, verify structure + assert len(tools_data) >= 1 + names = [ + tool.get("name") + for tool in tools_data + if isinstance(tool, dict) + ] + assert "get_word_length" in names + + # Ensure we found at least one span with tools data + assert tools_found, "No spans found with tools data" diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index a3c7bdd9d9..18968fb36a 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -1036,7 +1036,7 @@ def test_ai_client_span_responses_api(sentry_init, capture_events): assert spans[0]["origin"] == "auto.ai.openai" assert spans[0]["data"] == { "gen_ai.operation.name": "responses", - "gen_ai.request.messages": "How do I check if a Python object is an instance of a class?", + "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]', "gen_ai.request.model": "gpt-4o", "gen_ai.system": "openai", "gen_ai.response.model": "response-model-id", @@ -1045,7 +1045,7 @@ def test_ai_client_span_responses_api(sentry_init, capture_events): "gen_ai.usage.output_tokens": 10, "gen_ai.usage.output_tokens.reasoning": 8, "gen_ai.usage.total_tokens": 30, - "gen_ai.response.text": '[{"id": "message-id", "content": [{"annotations": [], "text": "the model response", "type": "output_text"}], "role": "assistant", "status": "completed", "type": "message"}]', + "gen_ai.response.text": "the model response", "thread.id": mock.ANY, "thread.name": mock.ANY, } @@ -1116,7 +1116,7 @@ async def test_ai_client_span_responses_async_api(sentry_init, capture_events): assert spans[0]["origin"] == "auto.ai.openai" assert spans[0]["data"] == { "gen_ai.operation.name": "responses", - "gen_ai.request.messages": "How do I check if a Python object is an instance of a class?", + "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]', "gen_ai.request.model": "gpt-4o", "gen_ai.response.model": "response-model-id", "gen_ai.system": "openai", @@ -1125,7 +1125,7 @@ async def test_ai_client_span_responses_async_api(sentry_init, capture_events): "gen_ai.usage.output_tokens": 10, "gen_ai.usage.output_tokens.reasoning": 8, "gen_ai.usage.total_tokens": 30, - "gen_ai.response.text": '[{"id": "message-id", "content": [{"annotations": [], "text": "the model response", "type": "output_text"}], "role": "assistant", "status": "completed", "type": "message"}]', + "gen_ai.response.text": "the model response", "thread.id": mock.ANY, "thread.name": mock.ANY, } @@ -1162,7 +1162,7 @@ async def test_ai_client_span_streaming_responses_async_api( assert spans[0]["origin"] == "auto.ai.openai" assert spans[0]["data"] == { "gen_ai.operation.name": "responses", - "gen_ai.request.messages": "How do I check if a Python object is an instance of a class?", + "gen_ai.request.messages": '["How do I check if a Python object is an instance of a class?"]', "gen_ai.request.model": "gpt-4o", "gen_ai.response.model": "response-model-id", "gen_ai.response.streaming": True, @@ -1172,7 +1172,7 @@ async def test_ai_client_span_streaming_responses_async_api( "gen_ai.usage.output_tokens": 10, "gen_ai.usage.output_tokens.reasoning": 8, "gen_ai.usage.total_tokens": 30, - "gen_ai.response.text": '[{"id": "message-id", "content": [{"annotations": [], "text": "the model response", "type": "output_text"}], "role": "assistant", "status": "completed", "type": "message"}]', + "gen_ai.response.text": "the model response", "thread.id": mock.ANY, "thread.name": mock.ANY, } @@ -1332,7 +1332,7 @@ def test_streaming_responses_api( assert span["op"] == "gen_ai.responses" if send_default_pii and include_prompts: - assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == "hello" + assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '["hello"]' assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "hello world" else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] @@ -1387,7 +1387,7 @@ async def test_streaming_responses_api_async( assert span["op"] == "gen_ai.responses" if send_default_pii and include_prompts: - assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == "hello" + assert span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '["hello"]' assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == "hello world" else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] diff --git a/tests/integrations/openai_agents/test_openai_agents.py b/tests/integrations/openai_agents/test_openai_agents.py index a3075e6415..fab8d9e13f 100644 --- a/tests/integrations/openai_agents/test_openai_agents.py +++ b/tests/integrations/openai_agents/test_openai_agents.py @@ -582,8 +582,9 @@ def simple_test_tool(message: str) -> str: assert ai_client_span2["data"]["gen_ai.request.model"] == "gpt-4" assert ai_client_span2["data"]["gen_ai.request.temperature"] == 0.7 assert ai_client_span2["data"]["gen_ai.request.top_p"] == 1.0 - assert ai_client_span2["data"]["gen_ai.response.text"] == safe_serialize( - ["Task completed using the tool"] + assert ( + ai_client_span2["data"]["gen_ai.response.text"] + == "Task completed using the tool" ) assert ai_client_span2["data"]["gen_ai.system"] == "openai" assert ai_client_span2["data"]["gen_ai.usage.input_tokens.cached"] == 0 From f78552480e894b7a5a152530c589d9677f81bc14 Mon Sep 17 00:00:00 2001 From: getsentry-bot Date: Fri, 5 Sep 2025 11:25:19 +0000 Subject: [PATCH 25/26] release: 2.37.0 --- CHANGELOG.md | 13 +++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c10ef8b7f..29dfdfff07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 2.37.0 + +### Various fixes & improvements + +- feat(agents): improve instrumentation of input messages (#4750) by @shellmayr +- tests: Move boto3 tests under toxgen (#4761) by @sentrivana +- ci: Fix celery (#4765) by @sentrivana +- Format span attributes in AI integrations (#4762) by @antonpirker +- tests: Move asyncpg under toxgen (#4757) by @sentrivana +- feat: Add LangGraph integration (#4727) by @shellmayr +- tests: Move beam under toxgen (#4759) by @sentrivana +- tests: Remove openai pin and update tox (#4748) by @sentrivana + ## 2.36.0 ### Various fixes & improvements diff --git a/docs/conf.py b/docs/conf.py index 835c20b112..935f45f6af 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.36.0" +release = "2.37.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 5480ef5dce..68a44fe88f 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1330,4 +1330,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.36.0" +VERSION = "2.37.0" diff --git a/setup.py b/setup.py index ca6e7ec534..8c4ea96ab9 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.36.0", + version="2.37.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From 75ef769d494c45e6f8b133da22fe75dcf9da713e Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Fri, 5 Sep 2025 13:31:56 +0200 Subject: [PATCH 26/26] Updated changelog --- CHANGELOG.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dfdfff07..52478dd4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,16 +2,19 @@ ## 2.37.0 -### Various fixes & improvements +- **New Integration (BETA):** Add support for `langgraph` (#4727) by @shellmayr + + We can now instrument AI agents that are created with [LangGraph](https://www.langchain.com/langgraph) out of the box. + + For more information see the [LangGraph integrations documentation](https://docs.sentry.io/platforms/python/integrations/langgraph/). -- feat(agents): improve instrumentation of input messages (#4750) by @shellmayr -- tests: Move boto3 tests under toxgen (#4761) by @sentrivana -- ci: Fix celery (#4765) by @sentrivana -- Format span attributes in AI integrations (#4762) by @antonpirker -- tests: Move asyncpg under toxgen (#4757) by @sentrivana -- feat: Add LangGraph integration (#4727) by @shellmayr -- tests: Move beam under toxgen (#4759) by @sentrivana -- tests: Remove openai pin and update tox (#4748) by @sentrivana +- AI Agents: Improve rendering of input and output messages in AI agents integrations. (#4750) by @shellmayr +- AI Agents: Format span attributes in AI integrations (#4762) by @antonpirker +- CI: Fix celery (#4765) by @sentrivana +- Tests: Move asyncpg under toxgen (#4757) by @sentrivana +- Tests: Move beam under toxgen (#4759) by @sentrivana +- Tests: Move boto3 tests under toxgen (#4761) by @sentrivana +- Tests: Remove openai pin and update tox (#4748) by @sentrivana ## 2.36.0