From fcaeb10080fc0aec1aa60099e602448cace374b0 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:14:35 -0700 Subject: [PATCH 01/21] Fix error when shutdown_agent called from harvest thread (#1552) --- newrelic/core/agent.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/newrelic/core/agent.py b/newrelic/core/agent.py index fbfc06b260..90690a573d 100644 --- a/newrelic/core/agent.py +++ b/newrelic/core/agent.py @@ -746,7 +746,12 @@ def shutdown_agent(self, timeout=None): self._harvest_thread.start() if self._harvest_thread.is_alive(): - self._harvest_thread.join(timeout) + try: + self._harvest_thread.join(timeout) + except RuntimeError: + # This can occur if the application is killed while in the harvest thread, + # causing shutdown_agent to be called from within the harvest thread. + pass def agent_instance(): From 996ee66664976997b6b4e6d77c4c89f3b1dce0ff Mon Sep 17 00:00:00 2001 From: canonrock16 <35710450+canonrock16@users.noreply.github.com> Date: Sat, 25 Oct 2025 04:41:40 +0900 Subject: [PATCH 02/21] fix(aiomysql): avoid wrapping pooled connections multiple times (#1553) * fix(aiomysql): avoid wrapping pooled connections multiple times * Move and rewrite regression test * Tweak implementation of fix --------- Co-authored-by: Tim Pansino --- newrelic/hooks/database_aiomysql.py | 4 +++ tests/datastore_aiomysql/test_database.py | 34 +++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/newrelic/hooks/database_aiomysql.py b/newrelic/hooks/database_aiomysql.py index 9a2f3d1d18..2cedcb40f9 100644 --- a/newrelic/hooks/database_aiomysql.py +++ b/newrelic/hooks/database_aiomysql.py @@ -78,6 +78,10 @@ async def _wrap_pool__acquire(wrapped, instance, args, kwargs): with FunctionTrace(name=callable_name(wrapped), terminal=True, rollup=rollup, source=wrapped): connection = await wrapped(*args, **kwargs) connection_kwargs = getattr(instance, "_conn_kwargs", {}) + + if hasattr(connection, "__wrapped__"): + return connection + return AsyncConnectionWrapper(connection, dbapi2_module, (((), connection_kwargs))) return _wrap_pool__acquire diff --git a/tests/datastore_aiomysql/test_database.py b/tests/datastore_aiomysql/test_database.py index 20d1a48586..8cc386cfe1 100644 --- a/tests/datastore_aiomysql/test_database.py +++ b/tests/datastore_aiomysql/test_database.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import inspect + import aiomysql from testing_support.db_settings import mysql_settings from testing_support.util import instance_hostname @@ -150,3 +152,35 @@ async def _test(): await pool.wait_closed() loop.run_until_complete(_test()) + + +@background_task() +def test_connection_pool_no_double_wrap(loop): + async def _test(): + pool = await aiomysql.create_pool( + db=DB_SETTINGS["name"], + user=DB_SETTINGS["user"], + password=DB_SETTINGS["password"], + host=DB_SETTINGS["host"], + port=DB_SETTINGS["port"], + loop=loop, + ) + + # Retrieve the same connection from the pool twice to see if it gets double wrapped + async with pool.acquire() as first_connection: + first_connection_unwrapped = inspect.unwrap(first_connection) + async with pool.acquire() as second_connection: + second_connection_unwrapped = inspect.unwrap(second_connection) + + # Ensure we actually retrieved the same underlying connection object from the pool twice + assert first_connection_unwrapped is second_connection_unwrapped, "Did not get same connection from pool" + + # Check that wrapping occurred only once + assert hasattr(first_connection, "__wrapped__"), "first_connection object was not wrapped" + assert hasattr(second_connection, "__wrapped__"), "second_connection object was not wrapped" + assert not hasattr(second_connection.__wrapped__, "__wrapped__"), "second_connection was double wrapped" + + pool.close() + await pool.wait_closed() + + loop.run_until_complete(_test()) From 6d7be8cc59ba2abf694731445e51fcd0d10aa710 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:13:47 -0700 Subject: [PATCH 03/21] Fix structlog tests (#1556) --- tests/logger_structlog/test_attributes.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/logger_structlog/test_attributes.py b/tests/logger_structlog/test_attributes.py index c41591f192..f76821cd4a 100644 --- a/tests/logger_structlog/test_attributes.py +++ b/tests/logger_structlog/test_attributes.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys + import pytest from testing_support.validators.validate_log_event_count import validate_log_event_count from testing_support.validators.validate_log_events import validate_log_events @@ -23,12 +25,18 @@ def logger(structlog_caplog): import structlog + # For Python < 3.11 co_qualname does not exist and causes errors. + # Remove it from the CallsiteParameterAdder input list. + _callsite_params = set(structlog.processors.CallsiteParameter) + if sys.version_info < (3, 11) and hasattr(structlog.processors.CallsiteParameter, "QUAL_NAME"): + _callsite_params.remove(structlog.processors.CallsiteParameter.QUAL_NAME) + structlog.configure( processors=[ structlog.contextvars.merge_contextvars, structlog.processors.format_exc_info, structlog.processors.StackInfoRenderer(), - structlog.processors.CallsiteParameterAdder(), + structlog.processors.CallsiteParameterAdder(parameters=_callsite_params), ], logger_factory=lambda *args, **kwargs: structlog_caplog, ) From f9ab47bf20069a7fa35aa1cc736f4cf1c5a58dda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 18:22:22 +0000 Subject: [PATCH 04/21] Bump the github_actions group with 4 updates (#1555) Bumps the github_actions group with 4 updates: [actions/upload-artifact](https://github.com/actions/upload-artifact), [actions/download-artifact](https://github.com/actions/download-artifact), [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) and [github/codeql-action](https://github.com/github/codeql-action). Updates `actions/upload-artifact` from 4.6.2 to 5.0.0 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...330a01c490aca151604b8cf639adc76d48f6c5d4) Updates `actions/download-artifact` from 5.0.0 to 6.0.0 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/634f93cb2916e3fdff6788551b99b062d0335ce0...018cc2cf5baa6db3ef3c5f8a56943fffe632ef53) Updates `astral-sh/setup-uv` from 7.1.1 to 7.1.2 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/2ddd2b9cb38ad8efd50337e8ab201519a34c9f24...85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41) Updates `github/codeql-action` from 4.30.9 to 4.31.0 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/16140ae1a102900babc80a33c44059580f687047...4e94bd11f71e507f7f87df81788dff88d1dacbfb) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github_actions - dependency-name: actions/download-artifact dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github_actions - dependency-name: astral-sh/setup-uv dependency-version: 7.1.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions - dependency-name: github/codeql-action dependency-version: 4.31.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> --- .github/workflows/build-ci-image.yml | 4 +- .github/workflows/deploy.yml | 6 +- .github/workflows/mega-linter.yml | 2 +- .github/workflows/tests.yml | 108 +++++++++++++-------------- .github/workflows/trivy.yml | 2 +- 5 files changed, 61 insertions(+), 61 deletions(-) diff --git a/.github/workflows/build-ci-image.yml b/.github/workflows/build-ci-image.yml index 8d56ad35c9..ab183f48a2 100644 --- a/.github/workflows/build-ci-image.yml +++ b/.github/workflows/build-ci-image.yml @@ -97,7 +97,7 @@ jobs: touch "${{ runner.temp }}/digests/${digest#sha256:}" - name: Upload Digest - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 with: name: digests-${{ matrix.cache_tag }} path: ${{ runner.temp }}/digests/* @@ -114,7 +114,7 @@ jobs: steps: - name: Download Digests - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # 6.0.0 with: path: ${{ runner.temp }}/digests pattern: digests-* diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2337ee8d40..81ed6f6be8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -97,7 +97,7 @@ jobs: CIBW_TEST_SKIP: "*-win_arm64" - name: Upload Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 with: name: ${{ github.job }}-${{ matrix.wheel }} path: ./wheelhouse/*.whl @@ -134,7 +134,7 @@ jobs: openssl md5 -binary "dist/${tarball}" | xxd -p | tr -d '\n' > "dist/${md5_file}" - name: Upload Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 with: name: ${{ github.job }}-sdist path: | @@ -166,7 +166,7 @@ jobs: environment: ${{ matrix.pypi-instance }} steps: - - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0 + - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # 6.0.0 with: path: ./dist/ merge-multiple: true diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index 17e65ed47a..8f74866d43 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -68,7 +68,7 @@ jobs: # Upload MegaLinter artifacts - name: Archive production artifacts if: success() || failure() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 with: name: MegaLinter reports include-hidden-files: "true" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc51fcb08c..711e1324ce 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,7 +100,7 @@ jobs: architecture: x64 - name: Download Coverage Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # 6.0.0 with: pattern: coverage-* path: ./ @@ -134,7 +134,7 @@ jobs: architecture: x64 - name: Download Results Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # 5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # 6.0.0 with: pattern: results-* path: ./ @@ -196,7 +196,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -206,7 +206,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -261,7 +261,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -271,7 +271,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -309,7 +309,7 @@ jobs: 3.14 - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # 7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 - name: Install Dependencies run: | @@ -333,7 +333,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -343,7 +343,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -381,7 +381,7 @@ jobs: 3.14 - name: Install uv - uses: astral-sh/setup-uv@2ddd2b9cb38ad8efd50337e8ab201519a34c9f24 # 7.1.1 + uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 - name: Install Dependencies run: | @@ -405,7 +405,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -415,7 +415,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -475,7 +475,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -485,7 +485,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -558,7 +558,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -568,7 +568,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -638,7 +638,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -648,7 +648,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -719,7 +719,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -729,7 +729,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -804,7 +804,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -814,7 +814,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -869,7 +869,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -879,7 +879,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -959,7 +959,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -969,7 +969,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1037,7 +1037,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1047,7 +1047,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1115,7 +1115,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1125,7 +1125,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1193,7 +1193,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1203,7 +1203,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1276,7 +1276,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1286,7 +1286,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1359,7 +1359,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1369,7 +1369,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1438,7 +1438,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1448,7 +1448,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1519,7 +1519,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1529,7 +1529,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1599,7 +1599,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1609,7 +1609,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1679,7 +1679,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1689,7 +1689,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1758,7 +1758,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1768,7 +1768,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1836,7 +1836,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1846,7 +1846,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -1955,7 +1955,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -1965,7 +1965,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -2035,7 +2035,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -2045,7 +2045,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} @@ -2113,7 +2113,7 @@ jobs: FORCE_COLOR: "true" - name: Upload Coverage Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: coverage-${{ github.job }}-${{ strategy.job-index }} @@ -2123,7 +2123,7 @@ jobs: retention-days: 1 - name: Upload Results Artifacts - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # 4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # 5.0.0 if: always() with: name: results-${{ github.job }}-${{ strategy.job-index }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 2016fe2121..9bf87921d7 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -61,6 +61,6 @@ jobs: - name: Upload Trivy scan results to GitHub Security tab if: ${{ github.event_name == 'schedule' }} - uses: github/codeql-action/upload-sarif@16140ae1a102900babc80a33c44059580f687047 # 4.30.9 + uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # 4.31.0 with: sarif_file: "trivy-results.sarif" From d1e7b60dd4896febf09e319e138363da7b15328b Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:39:56 -0700 Subject: [PATCH 05/21] Add instrumentation for new kinesis method (#1557) --- newrelic/hooks/external_botocore.py | 3 +++ tests/external_botocore/test_boto3_kinesis.py | 7 +++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/newrelic/hooks/external_botocore.py b/newrelic/hooks/external_botocore.py index d8c18b49db..28bd8ffb13 100644 --- a/newrelic/hooks/external_botocore.py +++ b/newrelic/hooks/external_botocore.py @@ -1451,6 +1451,9 @@ def wrap_serialize_to_request(wrapped, instance, args, kwargs): ("kinesis", "untag_resource"): aws_function_trace( "untag_resource", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), + ("kinesis", "update_max_record_size"): aws_function_trace( + "update_max_record_size", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" + ), ("kinesis", "update_shard_count"): aws_function_trace( "update_shard_count", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), diff --git a/tests/external_botocore/test_boto3_kinesis.py b/tests/external_botocore/test_boto3_kinesis.py index 9c03fa154a..9c92c669aa 100644 --- a/tests/external_botocore/test_boto3_kinesis.py +++ b/tests/external_botocore/test_boto3_kinesis.py @@ -46,6 +46,8 @@ } } +UNINSTRUMENTED_KINESIS_METHODS = ("generate_presigned_url", "close", "get_waiter", "can_paginate", "get_paginator") + _kinesis_scoped_metrics = [ (f"MessageBroker/Kinesis/Stream/Produce/Named/{TEST_STREAM}", 2), (f"MessageBroker/Kinesis/Stream/Consume/Named/{TEST_STREAM}", 1), @@ -117,10 +119,7 @@ def test_instrumented_kinesis_methods(): region_name=AWS_REGION, ) - ignored_methods = { - ("kinesis", method) - for method in ("generate_presigned_url", "close", "get_waiter", "can_paginate", "get_paginator") - } + ignored_methods = {("kinesis", method) for method in UNINSTRUMENTED_KINESIS_METHODS} client_methods = inspect.getmembers(client, predicate=inspect.ismethod) methods = {("kinesis", name) for (name, method) in client_methods if not name.startswith("_")} From 953f379e0fdf4b145b60fb19fdeeba55baed9190 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Thu, 30 Oct 2025 10:12:10 -0700 Subject: [PATCH 06/21] Add free-threaded Python to CI (#1562) --- .github/containers/Dockerfile | 2 +- .github/workflows/tests.yml | 34 ++++++++++++++-------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/.github/containers/Dockerfile b/.github/containers/Dockerfile index 207332f3c0..3f370a4a45 100644 --- a/.github/containers/Dockerfile +++ b/.github/containers/Dockerfile @@ -115,7 +115,7 @@ RUN mv "${HOME}/.local/bin/python3.11" "${HOME}/.local/bin/pypy3.11" && \ mv "${HOME}/.local/bin/python3.10" "${HOME}/.local/bin/pypy3.10" # Install CPython versions -RUN uv python install -f cp3.14 cp3.13 cp3.12 cp3.11 cp3.10 cp3.9 cp3.8 +RUN uv python install -f cp3.14 cp3.14t cp3.13 cp3.12 cp3.11 cp3.10 cp3.9 cp3.8 # Set default Python version to CPython 3.13 RUN uv python install -f --default cp3.13 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 711e1324ce..0d3f853f1d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -283,9 +283,8 @@ jobs: windows: env: TOTAL_GROUPS: 1 - UV_PYTHON: "3.13" - UV_PYTHON_DOWNLOADS: "never" - UV_PYTHON_PREFERENCE: "only-system" + UV_PYTHON_DOWNLOADS: "manual" + UV_PYTHON_PREFERENCE: "only-managed" strategy: fail-fast: false @@ -301,16 +300,14 @@ jobs: run: | git fetch --tags origin - - name: Install Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 - with: - python-version: | - 3.13 - 3.14 - - name: Install uv uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 + - name: Install Python + run: | + uv python install -f 3.13 3.14 3.14t + uv python install -f --default 3.13 + - name: Install Dependencies run: | uv tool install tox --with tox-uv @@ -355,9 +352,8 @@ jobs: windows_arm64: env: TOTAL_GROUPS: 1 - UV_PYTHON: "3.13" - UV_PYTHON_DOWNLOADS: "never" - UV_PYTHON_PREFERENCE: "only-system" + UV_PYTHON_DOWNLOADS: "manual" + UV_PYTHON_PREFERENCE: "only-managed" strategy: fail-fast: false @@ -373,16 +369,14 @@ jobs: run: | git fetch --tags origin - - name: Install Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 - with: - python-version: | - 3.13 - 3.14 - - name: Install uv uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 + - name: Install Python + run: | + uv python install -f 3.13 3.14 3.14t + uv python install -f --default 3.13 + - name: Install Dependencies run: | uv tool install tox --with tox-uv From 3ed4a12613bae3e8c87afda3a3b7362a2b424dc4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 17:08:44 +0000 Subject: [PATCH 07/21] Bump github/codeql-action in the github_actions group (#1566) Bumps the github_actions group with 1 update: [github/codeql-action](https://github.com/github/codeql-action). Updates `github/codeql-action` from 4.31.0 to 4.31.2 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/4e94bd11f71e507f7f87df81788dff88d1dacbfb...0499de31b99561a6d14a36a5f662c2a54f91beee) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.31.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/trivy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 9bf87921d7..c373a38bb1 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -61,6 +61,6 @@ jobs: - name: Upload Trivy scan results to GitHub Security tab if: ${{ github.event_name == 'schedule' }} - uses: github/codeql-action/upload-sarif@4e94bd11f71e507f7f87df81788dff88d1dacbfb # 4.31.0 + uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # 4.31.2 with: sarif_file: "trivy-results.sarif" From 2dd463d81aa2859ac7b0dba099389becd4e611ec Mon Sep 17 00:00:00 2001 From: Uma Annamalai Date: Mon, 3 Nov 2025 09:48:37 -0800 Subject: [PATCH 08/21] Region aware/ Claude 3+ bedrock support (#1561) * Modify extractor logic. * Add support for Claude Sonnet 3+ and region aware models. * Update claude content extraction logic. * Add support for Claude Sonnet 3+ and region aware models. * Update claude content extraction logic. * Add testing for aiobotocore. * Restore newline. --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- newrelic/hooks/external_botocore.py | 20 +- ...st_bedrock_chat_completion_invoke_model.py | 1 + ...ck_external_bedrock_server_invoke_model.py | 83 ++++++ .../_test_bedrock_chat_completion.py | 271 ++++++++++++++++++ ...st_bedrock_chat_completion_invoke_model.py | 2 +- ...t_bedrock_chat_completion_via_langchain.py | 1 + 6 files changed, 374 insertions(+), 4 deletions(-) diff --git a/newrelic/hooks/external_botocore.py b/newrelic/hooks/external_botocore.py index 28bd8ffb13..9d8e4eba89 100644 --- a/newrelic/hooks/external_botocore.py +++ b/newrelic/hooks/external_botocore.py @@ -394,7 +394,7 @@ def extract_bedrock_claude_model_request(request_body, bedrock_attrs): ] else: input_message_list = [{"role": "user", "content": request_body.get("prompt")}] - bedrock_attrs["request.max_tokens"] = request_body.get("max_tokens_to_sample") + bedrock_attrs["request.max_tokens"] = request_body.get("max_tokens_to_sample") or request_body.get("max_tokens") bedrock_attrs["request.temperature"] = request_body.get("temperature") bedrock_attrs["input_message_list"] = input_message_list @@ -406,7 +406,13 @@ def extract_bedrock_claude_model_response(response_body, bedrock_attrs): response_body = json.loads(response_body) role = response_body.get("role", "assistant") content = response_body.get("content") or response_body.get("completion") - output_message_list = [{"role": role, "content": content}] + + # For Claude Sonnet 3+ models, the content key holds a list with the type and text of the output + if isinstance(content, list): + output_message_list = [{"role": "assistant", "content": result.get("text")} for result in content] + else: + output_message_list = [{"role": role, "content": content}] + bedrock_attrs["response.choices.finish_reason"] = response_body.get("stop_reason") bedrock_attrs["output_message_list"] = output_message_list @@ -420,6 +426,7 @@ def extract_bedrock_claude_model_streaming_response(response_body, bedrock_attrs bedrock_attrs["output_message_list"] = [{"role": "assistant", "content": ""}] bedrock_attrs["output_message_list"][0]["content"] += content bedrock_attrs["response.choices.finish_reason"] = response_body.get("stop_reason") + return bedrock_attrs @@ -639,7 +646,7 @@ def _wrap_bedrock_runtime_invoke_model(wrapped, instance, args, kwargs): # Determine extractor by model type for extractor_name, request_extractor, response_extractor, stream_extractor in MODEL_EXTRACTORS: # noqa: B007 - if model.startswith(extractor_name): + if extractor_name in model: break else: # Model was not found in extractor list @@ -1057,6 +1064,13 @@ def handle_chat_completion_event(transaction, bedrock_attrs): input_message_list = bedrock_attrs.get("input_message_list", []) output_message_list = bedrock_attrs.get("output_message_list", []) + + no_output_content = len(output_message_list) == 1 and not output_message_list[0].get("content", "") + + # This checks handles Sonnet 3+ models which report an additional empty input and empty output in streaming cases after the main content has been generated + if not input_message_list and no_output_content: + return + number_of_messages = ( len(input_message_list) + len(output_message_list) ) or None # If 0, attribute will be set to None and removed diff --git a/tests/external_aiobotocore/test_bedrock_chat_completion_invoke_model.py b/tests/external_aiobotocore/test_bedrock_chat_completion_invoke_model.py index 65cb276c77..e02cc5b543 100644 --- a/tests/external_aiobotocore/test_bedrock_chat_completion_invoke_model.py +++ b/tests/external_aiobotocore/test_bedrock_chat_completion_invoke_model.py @@ -73,6 +73,7 @@ def request_streaming(request): "amazon.titan-text-express-v1", "ai21.j2-mid-v1", "anthropic.claude-instant-v1", + "anthropic.claude-3-sonnet-20240229-v1:0", "meta.llama2-13b-chat-v1", "mistral.mistral-7b-instruct-v0:2", ], diff --git a/tests/external_botocore/_mock_external_bedrock_server_invoke_model.py b/tests/external_botocore/_mock_external_bedrock_server_invoke_model.py index 5114a251fd..6dd1fbaac0 100644 --- a/tests/external_botocore/_mock_external_bedrock_server_invoke_model.py +++ b/tests/external_botocore/_mock_external_bedrock_server_invoke_model.py @@ -184,6 +184,27 @@ "0000009b0000004b22fa51700b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131397a64473977496977696157356b5a5867694f6a4239227dc0567ebe", ], ], + "anthropic.claude-3-sonnet-20240229-v1%3A0::The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.": [ + { + "Content-Type": "application/vnd.amazon.eventstream", + "x-amzn-RequestId": "e8fc1dd7-3d1e-42c6-9c58-535cae563bff", + }, + 200, + [ + "000002280000004b385582bf0b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56397a6447467964434973496d316c63334e685a3255694f6e7369615751694f694a7463326466596d5279613138774d544e765a4552425157646a61316c4d6548677a4d545a6e56484e68615651694c434a306558426c496a6f696257567a6332466e5a534973496e4a76624755694f694a6863334e7063335268626e51694c434a746232526c62434936496d4e735958566b5a53307a4c54637463323975626d56304c5449774d6a55774d6a45354969776959323975644756756443493657313073496e4e3062334266636d566863323975496a7075645778734c434a7a6447397758334e6c6358566c626d4e6c496a7075645778734c434a316332466e5a53493665794a70626e4231644639306232746c626e4d694f6a55334c434a6a59574e6f5a56396a636d566864476c76626c3970626e4231644639306232746c626e4d694f6a4173496d4e685932686c58334a6c595752666157357764585266644739725a57357a496a6f774c434a766458527764585266644739725a57357a496a6f7866583139222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a41424344227d61e93a7f000001090000004b4260f6a50b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131397a6447467964434973496d6c755a475634496a6f774c434a6a623235305a57353058324a7362324e72496a7037496e5235634755694f694a305a58683049697769644756346443493649694a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f5051227d389cc5b50000010b0000004b38a0a5c50b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f694a4a496e3139222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f5051525354555657227d44a91993", + "0000012f0000004b0ce12c010b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69496e625342795a57466b655342306279426f59585a6c494745696658303d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a303132333435363738227d800aab0f", + "000001250000004b465134a00b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949675a6e4a705a57356b62486b6759323975646d56796332463061573975494746755a434277636d39326157526c496e3139222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445227d802cf867", + "0000012e0000004b318105b10b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949675a47563059576c735a575167636d567a634739756332567a49474a686332566b494739754947313549477475623364735a57526e5a534a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142227da34e7683", + "000001040000004bbaf032140b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69497549456c6d49486c7664534268633273696658303d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a227d6c7c28fc", + "0000011e0000004b90a0bd370b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69496762575567633239745a58526f6157356e49456b675a4739754a33516761323576647977696658303d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a414243444546227d3d05ad95", + "000001010000004b7210bd640b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69496753536473624342695a53423163475a79623235304947466962335630496e3139222c2270223a226162636465666768696a6b227d54598ee9", + "000001240000004b7b311d100b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69496764476868644334675632686864434233623356735a4342356233556762476c725a53423062794a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748227d2ed8f3e1", + "000001110000004b12f02ae60b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949675a476c7a5933567a6379423062325268655438696658303d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a414243444546474849227d3a079dac", + "000000da0000004b476920890b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131397a64473977496977696157356b5a5867694f6a4239222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a30313233227dfb6c29f4", + "000001150000004be7708c260b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56396b5a57783059534973496d526c62485268496a7037496e4e3062334266636d566863323975496a6f695a57356b58335231636d34694c434a7a6447397758334e6c6358566c626d4e6c496a7075645778736653776964584e685a3255694f6e736962335630634856305833527661325675637949364e445a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f70717273227d4edf2495", + "000001750000004b7e42fb6b0b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56397a644739774969776959573168656d39754c574a6c5a484a76593273746157353262324e6864476c76626b316c64484a7059334d694f6e736961573577645852556232746c626b4e7664573530496a6f314e7977696233563063485630564739725a57354462335675644349364e445973496d6c75646d396a595852706232354d5958526c626d4e35496a6f784e546b774c434a6d61584a7a64454a356447564d5958526c626d4e35496a6f324d444a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a30313233343536227d62c2f995", + ], + ], "meta.llama2-13b-chat-v1::[INST] The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.": [ { "Content-Type": "application/vnd.amazon.eventstream", @@ -385,6 +406,23 @@ "0000016b0000004ba192d2880b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a6a623231776247563061573975496a6f694969776963335276634639795a57467a623234694f694a7a6447397758334e6c6358566c626d4e6c496977696333527663434936496c78755847354964573168626a6f694c434a686257463662323474596d566b636d396a61793170626e5a76593246306157397554575630636d6c6a6379493665794a70626e4231644652766132567551323931626e51694f6a45354c434a7664585277645852556232746c626b4e7664573530496a6f354f5377696157353262324e6864476c76626b78686447567559336b694f6a45314e7a4173496d5a70636e4e30516e6c305a5578686447567559336b694f6a51784d583139227d9a4fc171", ], ], + "anthropic.claude-3-sonnet-20240229-v1%3A0::What is 212 degrees Fahrenheit converted to Celsius?": [ + { + "Content-Type": "application/vnd.amazon.eventstream", + "x-amzn-RequestId": "1efe6197-80f9-43a6-89a5-bb536c1b822f", + }, + 200, + [ + "000002180000004b99743a390b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56397a6447467964434973496d316c63334e685a3255694f6e7369615751694f694a7463326466596d5279613138774d5574335a6c68345a4759345a474e52656b7734616c5a584e30704961554d694c434a306558426c496a6f696257567a6332466e5a534973496e4a76624755694f694a6863334e7063335268626e51694c434a746232526c62434936496d4e735958566b5a53307a4c54637463323975626d56304c5449774d6a55774d6a45354969776959323975644756756443493657313073496e4e3062334266636d566863323975496a7075645778734c434a7a6447397758334e6c6358566c626d4e6c496a7075645778734c434a316332466e5a53493665794a70626e4231644639306232746c626e4d694f6a49784c434a6a59574e6f5a56396a636d566864476c76626c3970626e4231644639306232746c626e4d694f6a4173496d4e685932686c58334a6c595752666157357764585266644739725a57357a496a6f774c434a766458527764585266644739725a57357a496a6f3066583139222c2270223a226162636465666768696a6b6c6d6e227d3d1a346f000000e80000004b9c88cb6f0b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131397a6447467964434973496d6c755a475634496a6f774c434a6a623235305a57353058324a7362324e72496a7037496e5235634755694f694a305a58683049697769644756346443493649694a3966513d3d222c2270223a226162636465666768696a227d9956467b000001110000004b12f02ae60b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949794d5449675a47566e636d566c63794a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d227de0817863", + "0000013f0000004b6c01bb830b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f694967526d466f636d567561475670644342706379426c63585670646d46735a57353049485276494445774d434a3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a3031323334227d4d984565", + "000001300000004bee512c520b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949675a47566e636d566c637942445a57787a6158567a4c6c78755847355561476c7a496e3139222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a3031227dcde98f72", + "000001380000004bde2167930b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f69496761584d676447686c49474a7661577870626d636763473970626e516762325967643246305a5849696658303d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a3031227d704e3e29", + "000001200000004b8eb1bbd00b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131396b5a57783059534973496d6c755a475634496a6f774c434a6b5a5778305953493665794a306558426c496a6f69644756346446396b5a57783059534973496e526c654851694f6949675958516763335268626d5268636d51675958527462334e776147567961574d6763484a6c63334e31636d5575496e3139222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a227d878c94d4", + "000000b40000004b616be9a50b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f695932397564475675644639696247396a6131397a64473977496977696157356b5a5867694f6a4239222c2270223a226162636465666768696a6b6c6d6e6f707172227d819037fa", + "000001350000004b26b1a3220b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56396b5a57783059534973496d526c62485268496a7037496e4e3062334266636d566863323975496a6f695a57356b58335231636d34694c434a7a6447397758334e6c6358566c626d4e6c496a7075645778736653776964584e685a3255694f6e736962335630634856305833527661325675637949364d7a523966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f50515253545556575859227d97f2c35e", + "000001730000004bf1020ecb0b3a6576656e742d747970650700056368756e6b0d3a636f6e74656e742d747970650700106170706c69636174696f6e2f6a736f6e0d3a6d6573736167652d747970650700056576656e747b226279746573223a2265794a306558426c496a6f696257567a6332466e5a56397a644739774969776959573168656d39754c574a6c5a484a76593273746157353262324e6864476c76626b316c64484a7059334d694f6e736961573577645852556232746c626b4e7664573530496a6f794d5377696233563063485630564739725a57354462335675644349364d7a5173496d6c75646d396a595852706232354d5958526c626d4e35496a6f784d5459334c434a6d61584a7a64454a356447564d5958526c626d4e35496a6f314e446c3966513d3d222c2270223a226162636465666768696a6b6c6d6e6f707172737475767778797a4142434445464748494a4b4c4d4e4f505152535455565758595a3031323334227dce4b9fd5", + ], + ], "cohere.command-text-v14::What is 212 degrees Fahrenheit converted to Celsius?": [ { "Content-Type": "application/vnd.amazon.eventstream", @@ -616,6 +654,24 @@ "usage": {"input_tokens": 73, "output_tokens": 13}, }, ], + "anthropic.claude-3-sonnet-20240229-v1%3A0::The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.": [ + {"Content-Type": "application/json", "x-amzn-RequestId": "96c7306d-2d60-4629-83e9-dbd6befb0e4e"}, + 200, + { + "id": "msg_bdrk_01QyLcwkWBVCzcNv97J8oC3Q", + "model": "claude-3-7-sonnet-20250219", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I'm ready for a friendly conversation! I'll share specific details when I can, and if I don't know something, I'll be straightforward about that. What would you like to talk about today?", + } + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 57, "output_tokens": 45}, + }, + ], "meta.llama2-13b-chat-v1::[INST] The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.": [ {"Content-Type": "application/json", "x-amzn-RequestId": "cce6b34c-812c-4f97-8885-515829aa9639"}, 200, @@ -824,6 +880,24 @@ "stop": None, }, ], + "anthropic.claude-3-sonnet-20240229-v1%3A0::What is 212 degrees Fahrenheit converted to Celsius?": [ + {"Content-Type": "application/json", "x-amzn-RequestId": "ab38295d-df9c-4141-8173-38221651bf46"}, + 200, + { + "id": "msg_bdrk_018mZM1sfTFG8NdbP2mZKZAy", + "model": "claude-3-7-sonnet-20250219", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "212 degrees Fahrenheit equals 100 degrees Celsius. This is the boiling point of water at standard atmospheric pressure.", + } + ], + "stop_reason": "end_turn", + "usage": {"input_tokens": 21, "output_tokens": 31}, + }, + ], "cohere.command-text-v14::What is 212 degrees Fahrenheit converted to Celsius?": [ {"Content-Type": "application/json", "x-amzn-RequestId": "12912a17-aa13-45f3-914c-cc82166f3601"}, 200, @@ -5048,6 +5122,15 @@ 403, {"message": "The security token included in the request is invalid."}, ], + "anthropic.claude-3-sonnet-20240229-v1%3A0::Invalid Token": [ + { + "Content-Type": "application/json", + "x-amzn-RequestId": "282ba076-576f-46aa-a2e6-680392132e87", + "x-amzn-ErrorType": "UnrecognizedClientException:http://internal.amazon.com/coral/com.amazon.coral.service/", + }, + 403, + {"message": "The security token included in the request is invalid."}, + ], "cohere.command-text-v14::Invalid Token": [ { "Content-Type": "application/json", diff --git a/tests/external_botocore/_test_bedrock_chat_completion.py b/tests/external_botocore/_test_bedrock_chat_completion.py index 155b6c993c..fd970b0603 100644 --- a/tests/external_botocore/_test_bedrock_chat_completion.py +++ b/tests/external_botocore/_test_bedrock_chat_completion.py @@ -19,6 +19,7 @@ "amazon.titan-text-express-v1": '{ "inputText": "%s", "textGenerationConfig": {"temperature": %f, "maxTokenCount": %d }}', "ai21.j2-mid-v1": '{"prompt": "%s", "temperature": %f, "maxTokens": %d}', "anthropic.claude-instant-v1": '{"prompt": "Human: %s Assistant:", "temperature": %f, "max_tokens_to_sample": %d}', + "anthropic.claude-3-sonnet-20240229-v1:0": '{"anthropic_version": "bedrock-2023-05-31", "messages": [{"role": "user", "content": "%s"}], "temperature": %f, "max_tokens": %d}', "cohere.command-text-v14": '{"prompt": "%s", "temperature": %f, "max_tokens": %d}', "meta.llama2-13b-chat-v1": '{"prompt": "%s", "temperature": %f, "max_gen_len": %d}', "mistral.mistral-7b-instruct-v0:2": '{"prompt": "[INST] %s [/INST]", "temperature": %f, "max_tokens": %d}', @@ -262,6 +263,65 @@ }, ), ], + "anthropic.claude-3-sonnet-20240229-v1:0": [ + ( + {"type": "LlmChatCompletionSummary"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "span_id": None, + "trace_id": "trace-id", + "request_id": "ab38295d-df9c-4141-8173-38221651bf46", + "duration": None, # Response time varies each test run + "request.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "request.temperature": 0.7, + "request.max_tokens": 100, + "response.choices.finish_reason": "end_turn", + "vendor": "bedrock", + "ingest_source": "Python", + "response.number_of_messages": 2, + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "ab38295d-df9c-4141-8173-38221651bf46", + "span_id": None, + "trace_id": "trace-id", + "content": "What is 212 degrees Fahrenheit converted to Celsius?", + "role": "user", + "completion_id": None, + "sequence": 0, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "ab38295d-df9c-4141-8173-38221651bf46", + "span_id": None, + "trace_id": "trace-id", + "content": "212 degrees Fahrenheit equals 100 degrees Celsius. This is the boiling point of water at standard atmospheric pressure.", + "role": "assistant", + "completion_id": None, + "sequence": 1, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + "is_response": True, + }, + ), + ], "cohere.command-text-v14": [ ( {"type": "LlmChatCompletionSummary"}, @@ -555,6 +615,62 @@ }, ), ], + "anthropic.claude-3-sonnet-20240229-v1:0": [ + ( + {"type": "LlmChatCompletionSummary"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "span_id": None, + "trace_id": "trace-id", + "request_id": "e8fc1dd7-3d1e-42c6-9c58-535cae563bff", + "duration": None, # Response time varies each test run + "request.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + "response.number_of_messages": 2, + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "e8fc1dd7-3d1e-42c6-9c58-535cae563bff", + "span_id": None, + "trace_id": "trace-id", + "content": "The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.\n\nCurrent conversation:\n\nHuman: Hi there!\nAI:", + "role": "user", + "completion_id": None, + "sequence": 0, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "e8fc1dd7-3d1e-42c6-9c58-535cae563bff", + "span_id": None, + "trace_id": "trace-id", + "content": "I'm ready for a friendly conversation! I'll share specific details when I can, and if I don't know something, I'll be straightforward about that. What would you like to talk about today?", + "role": "assistant", + "completion_id": None, + "sequence": 1, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + "is_response": True, + }, + ), + ], "meta.llama2-13b-chat-v1": [ ( {"type": "LlmChatCompletionSummary"}, @@ -787,6 +903,63 @@ }, ), ], + "anthropic.claude-3-sonnet-20240229-v1:0": [ + ( + {"type": "LlmChatCompletionSummary"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "span_id": None, + "trace_id": "trace-id", + "request_id": "96c7306d-2d60-4629-83e9-dbd6befb0e4e", + "duration": None, # Response time varies each test run + "request.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.choices.finish_reason": "end_turn", + "vendor": "bedrock", + "ingest_source": "Python", + "response.number_of_messages": 2, + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "96c7306d-2d60-4629-83e9-dbd6befb0e4e", + "span_id": None, + "trace_id": "trace-id", + "content": "The following is a friendly conversation between a human and an AI. The AI is talkative and provides lots of specific details from its context. If the AI does not know the answer to a question, it truthfully says it does not know.\n\nCurrent conversation:\n\nHuman: Hi there!\nAI:", + "role": "user", + "completion_id": None, + "sequence": 0, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "96c7306d-2d60-4629-83e9-dbd6befb0e4e", + "span_id": None, + "trace_id": "trace-id", + "content": "I'm ready for a friendly conversation! I'll share specific details when I can, and if I don't know something, I'll be straightforward about that. What would you like to talk about today?", + "role": "assistant", + "completion_id": None, + "sequence": 1, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + "is_response": True, + }, + ), + ], "meta.llama2-13b-chat-v1": [ ( {"type": "LlmChatCompletionSummary"}, @@ -1024,6 +1197,64 @@ }, ), ], + "anthropic.claude-3-sonnet-20240229-v1:0": [ + ( + {"type": "LlmChatCompletionSummary"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "span_id": None, + "trace_id": "trace-id", + "request_id": "1efe6197-80f9-43a6-89a5-bb536c1b822f", + "duration": None, # Response time varies each test run + "request.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "request.temperature": 0.7, + "request.max_tokens": 100, + "vendor": "bedrock", + "ingest_source": "Python", + "response.number_of_messages": 2, + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "1efe6197-80f9-43a6-89a5-bb536c1b822f", + "span_id": None, + "trace_id": "trace-id", + "content": "What is 212 degrees Fahrenheit converted to Celsius?", + "role": "user", + "completion_id": None, + "sequence": 0, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "1efe6197-80f9-43a6-89a5-bb536c1b822f", + "span_id": None, + "trace_id": "trace-id", + "content": "212 degrees Fahrenheit is equivalent to 100 degrees Celsius.\n\nThis is the boiling point of water at standard atmospheric pressure.", + "role": "assistant", + "completion_id": None, + "sequence": 1, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + "is_response": True, + }, + ), + ], "cohere.command-text-v14": [ ( {"type": "LlmChatCompletionSummary"}, @@ -1326,6 +1557,46 @@ }, ), ], + "anthropic.claude-3-sonnet-20240229-v1:0": [ + ( + {"type": "LlmChatCompletionSummary"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "span_id": None, + "trace_id": "trace-id", + "request_id": "282ba076-576f-46aa-a2e6-680392132e87", + "duration": None, # Response time varies each test run + "request.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "request.temperature": 0.7, + "request.max_tokens": 100, + "vendor": "bedrock", + "ingest_source": "Python", + "response.number_of_messages": 1, + "error": True, + }, + ), + ( + {"type": "LlmChatCompletionMessage"}, + { + "id": None, # UUID that varies with each run + "llm.conversation_id": "my-awesome-id", + "llm.foo": "bar", + "request_id": "282ba076-576f-46aa-a2e6-680392132e87", + "span_id": None, + "trace_id": "trace-id", + "content": "Invalid Token", + "role": "user", + "completion_id": None, + "sequence": 0, + "response.model": "anthropic.claude-3-sonnet-20240229-v1:0", + "vendor": "bedrock", + "ingest_source": "Python", + }, + ), + ], "cohere.command-text-v14": [ ( {"type": "LlmChatCompletionSummary"}, diff --git a/tests/external_botocore/test_bedrock_chat_completion_invoke_model.py b/tests/external_botocore/test_bedrock_chat_completion_invoke_model.py index 94a88e7a56..4422685b9f 100644 --- a/tests/external_botocore/test_bedrock_chat_completion_invoke_model.py +++ b/tests/external_botocore/test_bedrock_chat_completion_invoke_model.py @@ -73,6 +73,7 @@ def request_streaming(request): "amazon.titan-text-express-v1", "ai21.j2-mid-v1", "anthropic.claude-instant-v1", + "anthropic.claude-3-sonnet-20240229-v1:0", "cohere.command-text-v14", "meta.llama2-13b-chat-v1", "mistral.mistral-7b-instruct-v0:2", @@ -107,7 +108,6 @@ def _exercise_streaming_model(prompt, temperature=0.7, max_tokens=100): body = (payload_template % (prompt, temperature, max_tokens)).encode("utf-8") if request_streaming: body = BytesIO(body) - response = bedrock_server.invoke_model_with_response_stream( body=body, modelId=model_id, accept="application/json", contentType="application/json" ) diff --git a/tests/external_botocore/test_bedrock_chat_completion_via_langchain.py b/tests/external_botocore/test_bedrock_chat_completion_via_langchain.py index d68b636df2..82537cd10a 100644 --- a/tests/external_botocore/test_bedrock_chat_completion_via_langchain.py +++ b/tests/external_botocore/test_bedrock_chat_completion_via_langchain.py @@ -37,6 +37,7 @@ "amazon.titan-text-express-v1", "ai21.j2-mid-v1", "anthropic.claude-instant-v1", + "anthropic.claude-3-sonnet-20240229-v1:0", "cohere.command-text-v14", "meta.llama2-13b-chat-v1", ], From b9d9d3bf97890645f30089e66dfffa3080acaf64 Mon Sep 17 00:00:00 2001 From: Uma Annamalai Date: Mon, 3 Nov 2025 12:40:51 -0800 Subject: [PATCH 09/21] Fix notice_error logic for non-iterable exceptions. (#1564) --- newrelic/api/time_trace.py | 13 ++++++++++--- newrelic/core/stats_engine.py | 12 +++++++++--- tests/agent_features/test_notice_error.py | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/newrelic/api/time_trace.py b/newrelic/api/time_trace.py index fd0f62fdef..43b1ea2299 100644 --- a/newrelic/api/time_trace.py +++ b/newrelic/api/time_trace.py @@ -360,16 +360,23 @@ def _observe_exception(self, exc_info=None, ignore=None, expected=None, status_c return fullname, message, message_raw, tb, is_expected def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=None): + def error_is_iterable(error): + return hasattr(error, "__iter__") and not isinstance(error, (str, bytes)) + + def none_in_error(error): + return error_is_iterable(error) and None in error + attributes = attributes if attributes is not None else {} # If no exception details provided, use current exception. - # Pull from sys.exc_info if no exception is passed - if not error or None in error: + # Pull from sys.exc_info() if no exception is passed + # Check that the error exists and that it is a fully populated iterable + if not error or none_in_error(error) or (error and not error_is_iterable(error)): error = sys.exc_info() # If no exception to report, exit - if not error or None in error: + if not error or none_in_error(error) or (error and not error_is_iterable(error)): return exc, value, tb = error diff --git a/newrelic/core/stats_engine.py b/newrelic/core/stats_engine.py index f44f82fe13..b6d2092d99 100644 --- a/newrelic/core/stats_engine.py +++ b/newrelic/core/stats_engine.py @@ -676,9 +676,14 @@ def record_time_metrics(self, metrics): self.record_time_metric(metric) def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=None): + def error_is_iterable(error): + return hasattr(error, "__iter__") and not isinstance(error, (str, bytes)) + + def none_in_error(error): + return error_is_iterable(error) and None in error + attributes = attributes if attributes is not None else {} settings = self.__settings - if not settings: return @@ -691,11 +696,12 @@ def notice_error(self, error=None, attributes=None, expected=None, ignore=None, return # Pull from sys.exc_info if no exception is passed - if not error or None in error: + # Check that the error exists and that it is a fully populated iterable + if not error or none_in_error(error) or (error and not error_is_iterable(error)): error = sys.exc_info() # If no exception to report, exit - if not error or None in error: + if not error or none_in_error(error) or (error and not error_is_iterable(error)): return exc, value, tb = error diff --git a/tests/agent_features/test_notice_error.py b/tests/agent_features/test_notice_error.py index e698dee7be..636dadc09c 100644 --- a/tests/agent_features/test_notice_error.py +++ b/tests/agent_features/test_notice_error.py @@ -39,6 +39,25 @@ # =============== Test errors during a transaction =============== +_key_error_name = callable_name(KeyError) + +_test_notice_error_exception_object = [(_key_error_name, "'f'")] + + +@validate_transaction_errors(errors=_test_notice_error_exception_object) +@background_task() +def test_notice_error_non_iterable_object(): + """Test that notice_error works when passed an exception object directly""" + try: + test_dict = {"a": 4, "b": 5} + # Force a KeyError + test_dict["f"] + except KeyError as e: + # The caught exception here is a non-iterable, singular KeyError object with no associated traceback + # This will exercise logic to pull from sys.exc_info() instead of using the exception directly + notice_error(e) + + _test_notice_error_sys_exc_info = [(_runtime_error_name, "one")] From 4cb31b1265e4fce4b49ee298464f3a951103797a Mon Sep 17 00:00:00 2001 From: Uma Annamalai Date: Mon, 3 Nov 2025 14:02:09 -0800 Subject: [PATCH 10/21] Revert "Fix notice_error logic for non-iterable exceptions. (#1564)" (#1568) This reverts commit b9d9d3bf97890645f30089e66dfffa3080acaf64. --- newrelic/api/time_trace.py | 13 +++---------- newrelic/core/stats_engine.py | 12 +++--------- tests/agent_features/test_notice_error.py | 19 ------------------- 3 files changed, 6 insertions(+), 38 deletions(-) diff --git a/newrelic/api/time_trace.py b/newrelic/api/time_trace.py index 43b1ea2299..fd0f62fdef 100644 --- a/newrelic/api/time_trace.py +++ b/newrelic/api/time_trace.py @@ -360,23 +360,16 @@ def _observe_exception(self, exc_info=None, ignore=None, expected=None, status_c return fullname, message, message_raw, tb, is_expected def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=None): - def error_is_iterable(error): - return hasattr(error, "__iter__") and not isinstance(error, (str, bytes)) - - def none_in_error(error): - return error_is_iterable(error) and None in error - attributes = attributes if attributes is not None else {} # If no exception details provided, use current exception. - # Pull from sys.exc_info() if no exception is passed - # Check that the error exists and that it is a fully populated iterable - if not error or none_in_error(error) or (error and not error_is_iterable(error)): + # Pull from sys.exc_info if no exception is passed + if not error or None in error: error = sys.exc_info() # If no exception to report, exit - if not error or none_in_error(error) or (error and not error_is_iterable(error)): + if not error or None in error: return exc, value, tb = error diff --git a/newrelic/core/stats_engine.py b/newrelic/core/stats_engine.py index b6d2092d99..f44f82fe13 100644 --- a/newrelic/core/stats_engine.py +++ b/newrelic/core/stats_engine.py @@ -676,14 +676,9 @@ def record_time_metrics(self, metrics): self.record_time_metric(metric) def notice_error(self, error=None, attributes=None, expected=None, ignore=None, status_code=None): - def error_is_iterable(error): - return hasattr(error, "__iter__") and not isinstance(error, (str, bytes)) - - def none_in_error(error): - return error_is_iterable(error) and None in error - attributes = attributes if attributes is not None else {} settings = self.__settings + if not settings: return @@ -696,12 +691,11 @@ def none_in_error(error): return # Pull from sys.exc_info if no exception is passed - # Check that the error exists and that it is a fully populated iterable - if not error or none_in_error(error) or (error and not error_is_iterable(error)): + if not error or None in error: error = sys.exc_info() # If no exception to report, exit - if not error or none_in_error(error) or (error and not error_is_iterable(error)): + if not error or None in error: return exc, value, tb = error diff --git a/tests/agent_features/test_notice_error.py b/tests/agent_features/test_notice_error.py index 636dadc09c..e698dee7be 100644 --- a/tests/agent_features/test_notice_error.py +++ b/tests/agent_features/test_notice_error.py @@ -39,25 +39,6 @@ # =============== Test errors during a transaction =============== -_key_error_name = callable_name(KeyError) - -_test_notice_error_exception_object = [(_key_error_name, "'f'")] - - -@validate_transaction_errors(errors=_test_notice_error_exception_object) -@background_task() -def test_notice_error_non_iterable_object(): - """Test that notice_error works when passed an exception object directly""" - try: - test_dict = {"a": 4, "b": 5} - # Force a KeyError - test_dict["f"] - except KeyError as e: - # The caught exception here is a non-iterable, singular KeyError object with no associated traceback - # This will exercise logic to pull from sys.exc_info() instead of using the exception directly - notice_error(e) - - _test_notice_error_sys_exc_info = [(_runtime_error_name, "one")] From 8577eb7efcb18dd68359da60a23ac9b2f34de7c4 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:34:20 -0800 Subject: [PATCH 11/21] Add additional trace points for AWS Kinesis (#1569) --- newrelic/hooks/external_botocore.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/newrelic/hooks/external_botocore.py b/newrelic/hooks/external_botocore.py index 9d8e4eba89..39317ea752 100644 --- a/newrelic/hooks/external_botocore.py +++ b/newrelic/hooks/external_botocore.py @@ -1388,6 +1388,7 @@ def wrap_serialize_to_request(wrapped, instance, args, kwargs): extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis", ), + ("kinesis", "describe_account_settings"): aws_function_trace("describe_account_settings", library="Kinesis"), ("kinesis", "describe_limits"): aws_function_trace("describe_limits", library="Kinesis"), ("kinesis", "describe_stream"): aws_function_trace( "describe_stream", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" @@ -1465,6 +1466,7 @@ def wrap_serialize_to_request(wrapped, instance, args, kwargs): ("kinesis", "untag_resource"): aws_function_trace( "untag_resource", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), + ("kinesis", "update_account_settings"): aws_function_trace("update_account_settings", library="Kinesis"), ("kinesis", "update_max_record_size"): aws_function_trace( "update_max_record_size", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), @@ -1474,6 +1476,12 @@ def wrap_serialize_to_request(wrapped, instance, args, kwargs): ("kinesis", "update_stream_mode"): aws_function_trace( "update_stream_mode", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), + ("kinesis", "update_stream_warm_throughput"): aws_function_trace( + "update_stream_warm_throughput", + extract_kinesis, + extract_agent_attrs=extract_kinesis_agent_attrs, + library="Kinesis", + ), ("kinesis", "put_record"): aws_message_trace( "Produce", "Stream", extract_kinesis, extract_agent_attrs=extract_kinesis_agent_attrs, library="Kinesis" ), From 8c91a741e03bd2b9b05c8fdb51bb836a7d1928b3 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei <84813886+lrafeei@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:53:08 -0800 Subject: [PATCH 12/21] Enable environment variables for attribute filters (#1558) * Enable env vars for attribute filters * [MegaLinter] Apply linters fixes * Trigger tests * Change attribute filters to space delimited * Fix test assertion --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Tim Pansino --- newrelic/core/config.py | 76 ++++++++++++++------ tests/agent_unittests/test_agent_protocol.py | 2 +- 2 files changed, 56 insertions(+), 22 deletions(-) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 8108737c5d..8cfdeda0ae 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -803,9 +803,9 @@ def default_otlp_host(host): _settings.compressed_content_encoding = "gzip" _settings.max_payload_size_in_bytes = 1000000 -_settings.attributes.enabled = True -_settings.attributes.exclude = [] -_settings.attributes.include = [] +_settings.attributes.enabled = _environ_as_bool("NEW_RELIC_ATTRIBUTES_ENABLED", default=True) +_settings.attributes.exclude = _environ_as_set(os.environ.get("NEW_RELIC_ATTRIBUTES_EXCLUDE", "")) +_settings.attributes.include = _environ_as_set(os.environ.get("NEW_RELIC_ATTRIBUTES_INCLUDE", "")) _settings.thread_profiler.enabled = True _settings.cross_application_tracer.enabled = False @@ -821,9 +821,15 @@ def default_otlp_host(host): _settings.event_harvest_config.harvest_limits.analytic_event_data = _environ_as_int( "NEW_RELIC_ANALYTICS_EVENTS_MAX_SAMPLES_STORED", default=DEFAULT_RESERVOIR_SIZE ) -_settings.transaction_events.attributes.enabled = True -_settings.transaction_events.attributes.exclude = [] -_settings.transaction_events.attributes.include = [] +_settings.transaction_events.attributes.enabled = _environ_as_bool( + "NEW_RELIC_TRANSACTION_EVENTS_ATTRIBUTES_ENABLED", default=True +) +_settings.transaction_events.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_EVENTS_ATTRIBUTES_EXCLUDE", "") +) +_settings.transaction_events.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_EVENTS_ATTRIBUTES_INCLUDE", "") +) _settings.custom_insights_events.enabled = True _settings.event_harvest_config.harvest_limits.custom_event_data = _environ_as_int( @@ -847,13 +853,23 @@ def default_otlp_host(host): _settings.event_harvest_config.harvest_limits.span_event_data = _environ_as_int( "NEW_RELIC_SPAN_EVENTS_MAX_SAMPLES_STORED", default=SPAN_EVENT_RESERVOIR_SIZE ) -_settings.span_events.attributes.enabled = True -_settings.span_events.attributes.exclude = [] -_settings.span_events.attributes.include = [] +_settings.span_events.attributes.enabled = _environ_as_bool("NEW_RELIC_SPAN_EVENTS_ATTRIBUTES_ENABLED", default=True) +_settings.span_events.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_SPAN_EVENTS_ATTRIBUTES_EXCLUDE", "") +) +_settings.span_events.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_SPAN_EVENTS_ATTRIBUTES_INCLUDE", "") +) -_settings.transaction_segments.attributes.enabled = True -_settings.transaction_segments.attributes.exclude = [] -_settings.transaction_segments.attributes.include = [] +_settings.transaction_segments.attributes.enabled = _environ_as_bool( + "NEW_RELIC_TRANSACTION_SEGMENTS_ATTRIBUTES_ENABLED", default=True +) +_settings.transaction_segments.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_SEGMENTS_ATTRIBUTES_EXCLUDE", "") +) +_settings.transaction_segments.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_SEGMENTS_ATTRIBUTES_INCLUDE", "") +) _settings.transaction_tracer.enabled = True _settings.transaction_tracer.transaction_threshold = None @@ -864,9 +880,15 @@ def default_otlp_host(host): _settings.transaction_tracer.function_trace = [] _settings.transaction_tracer.generator_trace = [] _settings.transaction_tracer.top_n = 20 -_settings.transaction_tracer.attributes.enabled = True -_settings.transaction_tracer.attributes.exclude = [] -_settings.transaction_tracer.attributes.include = [] +_settings.transaction_tracer.attributes.enabled = _environ_as_bool( + "NEW_RELIC_TRANSACTION_TRACER_ATTRIBUTES_ENABLED", default=True +) +_settings.transaction_tracer.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_TRACER_ATTRIBUTES_EXCLUDE", "") +) +_settings.transaction_tracer.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_TRANSACTION_TRACER_ATTRIBUTES_INCLUDE", "") +) _settings.error_collector.enabled = True _settings.error_collector.capture_events = True @@ -879,9 +901,15 @@ def default_otlp_host(host): ) _settings.error_collector.expected_status_codes = set() _settings.error_collector._error_group_callback = None -_settings.error_collector.attributes.enabled = True -_settings.error_collector.attributes.exclude = [] -_settings.error_collector.attributes.include = [] +_settings.error_collector.attributes.enabled = _environ_as_bool( + "NEW_RELIC_ERROR_COLLECTOR_ATTRIBUTES_ENABLED", default=True +) +_settings.error_collector.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_ERROR_COLLECTOR_ATTRIBUTES_EXCLUDE", "") +) +_settings.error_collector.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_ERROR_COLLECTOR_ATTRIBUTES_INCLUDE", "") +) _settings.browser_monitoring.enabled = True _settings.browser_monitoring.auto_instrument = True @@ -890,9 +918,15 @@ def default_otlp_host(host): _settings.browser_monitoring.debug = False _settings.browser_monitoring.ssl_for_http = None _settings.browser_monitoring.content_type = ["text/html"] -_settings.browser_monitoring.attributes.enabled = False -_settings.browser_monitoring.attributes.exclude = [] -_settings.browser_monitoring.attributes.include = [] +_settings.browser_monitoring.attributes.enabled = _environ_as_bool( + "NEW_RELIC_BROWSER_MONITORING_ATTRIBUTES_ENABLED", default=False +) +_settings.browser_monitoring.attributes.exclude = _environ_as_set( + os.environ.get("NEW_RELIC_BROWSER_MONITORING_ATTRIBUTES_EXCLUDE", "") +) +_settings.browser_monitoring.attributes.include = _environ_as_set( + os.environ.get("NEW_RELIC_BROWSER_MONITORING_ATTRIBUTES_INCLUDE", "") +) _settings.transaction_name.limit = None _settings.transaction_name.naming_scheme = os.environ.get("NEW_RELIC_TRANSACTION_NAMING_SCHEME") diff --git a/tests/agent_unittests/test_agent_protocol.py b/tests/agent_unittests/test_agent_protocol.py index 54c5cd4d14..e6f0a04af3 100644 --- a/tests/agent_unittests/test_agent_protocol.py +++ b/tests/agent_unittests/test_agent_protocol.py @@ -466,7 +466,7 @@ def test_connect( # Verify that agent settings sent have converted null, containers, and # unserializable types to string assert agent_settings_payload["proxy_host"] == "None" - assert agent_settings_payload["attributes.include"] == "[]" + assert agent_settings_payload["attributes.include"] == str(set()) assert agent_settings_payload["feature_flag"] == str(set()) assert isinstance(agent_settings_payload["attribute_filter"], str) From 68252f007b102142033ae97b66b862aaa1ee33c0 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 3 Nov 2025 15:46:26 -0800 Subject: [PATCH 13/21] Update version of cibuildwheel to latest (#1570) --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 81ed6f6be8..8b469eaacb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -81,7 +81,7 @@ jobs: platforms: arm64 - name: Build Wheels - uses: pypa/cibuildwheel@faf86a6ed7efa889faf6996aa23820831055001a # 2.23.3 + uses: pypa/cibuildwheel@9c00cb4f6b517705a3794b22395aedc36257242c # 3.2.1 env: CIBW_PLATFORM: auto CIBW_BUILD: "${{ matrix.wheel }}*" From 81d01e1575a8ea69e14d1c8fa156fe0106d4d085 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:01:25 -0800 Subject: [PATCH 14/21] Force uv to use non-emulated Python on windows arm64 (#1567) Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/tests.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0d3f853f1d..e9ef7b2d4e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -374,8 +374,12 @@ jobs: - name: Install Python run: | - uv python install -f 3.13 3.14 3.14t - uv python install -f --default 3.13 + uv python install -f \ + cpython-3.13-windows-aarch64-none \ + cpython-3.14-windows-aarch64-none \ + cpython-3.14t-windows-aarch64-none + uv python install -f --default \ + cpython-3.13-windows-aarch64-none - name: Install Dependencies run: | From 8d18c0a9047b1a8695051df85fbdf8510e380a76 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei <84813886+lrafeei@users.noreply.github.com> Date: Fri, 14 Nov 2025 10:47:21 -0800 Subject: [PATCH 15/21] Skip hypercorn tests for v0.18 (#1579) * Skip hypercorn tests for v0.18 * Remove tornadomaster for 3.14 --- tox.ini | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 39148b657f..e27ce2ef83 100644 --- a/tox.ini +++ b/tox.ini @@ -113,8 +113,8 @@ envlist = python-adapter_gunicorn-{py38,py39,py310,py311,py312,py313}-aiohttp03-gunicornlatest, ;; Package not ready for Python 3.14 (aiohttp's worker not updated) ; python-adapter_gunicorn-py314-aiohttp03-gunicornlatest, - python-adapter_hypercorn-{py38,py39,py310,py311,py312,py313,py314}-hypercornlatest, - python-adapter_hypercorn-py38-hypercorn{0010,0011,0012,0013}, + python-adapter_hypercorn-{py310,py311,py312,py313,py314}-hypercornlatest, + python-adapter_hypercorn-{py38,py39}-hypercorn{0010,0011,0012,0013}, python-adapter_mcp-{py310,py311,py312,py313,py314}, python-adapter_uvicorn-{py38,py39,py310,py311,py312,py313,py314}-uvicornlatest, python-adapter_uvicorn-py38-uvicorn014, @@ -176,7 +176,8 @@ envlist = python-framework_strawberry-{py38,py39,py310,py311,py312}-strawberry02352, python-framework_strawberry-{py38,py39,py310,py311,py312,py313,py314}-strawberrylatest, python-framework_tornado-{py38,py39,py310,py311,py312,py313,py314}-tornadolatest, - python-framework_tornado-{py310,py311,py312,py313,py314}-tornadomaster, + ; Remove `python-framework_tornado-py314-tornadomaster` temporarily + python-framework_tornado-{py310,py311,py312,py313}-tornadomaster, python-logger_logging-{py38,py39,py310,py311,py312,py313,py314,pypy311}, python-logger_loguru-{py38,py39,py310,py311,py312,py313,py314,pypy311}-logurulatest, python-logger_structlog-{py38,py39,py310,py311,py312,py313,py314,pypy311}-structloglatest, @@ -231,7 +232,7 @@ deps = adapter_gunicorn-aiohttp03: aiohttp<4.0 adapter_gunicorn-gunicorn19: gunicorn<20 adapter_gunicorn-gunicornlatest: gunicorn - adapter_hypercorn-hypercornlatest: hypercorn[h3] + adapter_hypercorn-hypercornlatest: hypercorn[h3]!=0.18 adapter_hypercorn-hypercorn0013: hypercorn[h3]<0.14 adapter_hypercorn-hypercorn0012: hypercorn[h3]<0.13 adapter_hypercorn-hypercorn0011: hypercorn[h3]<0.12 From 1708a4df46e46643170ea507bc0bfe9b32a997d1 Mon Sep 17 00:00:00 2001 From: Hannah Stepanek Date: Tue, 11 Nov 2025 16:51:30 -0800 Subject: [PATCH 16/21] Add support for *.adaptive.sampling_target --- newrelic/api/transaction.py | 8 +- newrelic/config.py | 114 ++++++++++++- newrelic/core/config.py | 64 ++++++- .../test_distributed_tracing.py | 22 +-- .../test_distributed_tracing_settings.py | 159 +++++++++++++++++- 5 files changed, 339 insertions(+), 28 deletions(-) diff --git a/newrelic/api/transaction.py b/newrelic/api/transaction.py index 8a5ba37a26..3356b05022 100644 --- a/newrelic/api/transaction.py +++ b/newrelic/api/transaction.py @@ -1084,8 +1084,8 @@ def _make_sampling_decision(self): priority, sampled, full_granularity=True, - remote_parent_sampled_setting=self.settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled, - remote_parent_not_sampled_setting=self.settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled, + remote_parent_sampled_setting=self.settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled, + remote_parent_not_sampled_setting=self.settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled, ) _logger.debug("Full granularity sampling decision was %s with priority=%s.", sampled, priority) if computed_sampled or not self.settings.distributed_tracing.sampler.partial_granularity.enabled: @@ -1101,8 +1101,8 @@ def _make_sampling_decision(self): priority, sampled, full_granularity=False, - remote_parent_sampled_setting=self.settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled, - remote_parent_not_sampled_setting=self.settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled, + remote_parent_sampled_setting=self.settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled, + remote_parent_not_sampled_setting=self.settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled, ) _logger.debug( "Partial granularity sampling decision was %s with priority=%s.", self._sampled, self._priority diff --git a/newrelic/config.py b/newrelic/config.py index 41d118961f..96c41c2271 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -337,7 +337,11 @@ def _process_dt_setting(section, option_p1, option_p2, getter): while True: if len(fields) == 1: value = value1 or value2 or "default" - setattr(target, fields[0], value) + # Store the value at the underscored location so if option_p1 is + # distributed_tracing.sampler.full_granularity.remote_parent_sampled + # store it at location + # distributed_tracing.sampler.full_granularity._remote_parent_sampled + setattr(target, f"_{fields[0]}", value) break target = getattr(target, fields[0]) fields = fields[1].split(".", 1) @@ -360,6 +364,90 @@ def _process_dt_setting(section, option_p1, option_p2, getter): _raise_configuration_error(section, option_p1) +def _process_dt_hidden_setting(section, option, getter): + try: + # The type of a value is dictated by the getter + # function supplied. + + value = getattr(_config_object, getter)(section, option) + + # Now need to apply the option from the + # configuration file to the internal settings + # object. Walk the object path and assign it. + + target = _settings + fields = option.split(".", 1) + + while True: + if len(fields) == 1: + value = value or "default" + # Store the value at the underscored location so if option is + # distributed_tracing.sampler.full_granularity.remote_parent_sampled + # store it at location + # distributed_tracing.sampler.full_granularity._remote_parent_sampled + setattr(target, f"_{fields[0]}", value) + break + target = getattr(target, fields[0]) + fields = fields[1].split(".", 1) + + # Cache the configuration so can be dumped out to + # log file when whole main configuration has been + # processed. This ensures that the log file and log + # level entries have been set. + + _cache_object.append((option, value)) + + except configparser.NoSectionError: + pass + + except configparser.NoOptionError: + pass + + except Exception: + _raise_configuration_error(section, option) + +def _process_dt_sampler_setting(section, option, getter): + try: + # The type of a value is dictated by the getter + # function supplied. + + value = getattr(_config_object, getter)(section, option) + + # Now need to apply the option from the + # configuration file to the internal settings + # object. Walk the object path and assign it. + + target = _settings + fields = option.split(".", 1) + + while True: + if len(fields) == 1: + setattr(target, f"{fields[0]}", value) + break + elif fields[0] in ("root", "remote_parent_sampled", "remote_parent_not_sampled"): + sampler = fields[1].split(".", 1)[0] + setattr(target, f"_{fields[0]}", sampler) + target = getattr(target, fields[0]) + fields = fields[1].split(".", 1) + + + # Cache the configuration so can be dumped out to + # log file when whole main configuration has been + # processed. This ensures that the log file and log + # level entries have been set. + + _cache_object.append((option, value)) + + except configparser.NoSectionError: + pass + + except configparser.NoOptionError: + pass + + except Exception: + _raise_configuration_error(section, option) + + # Processing of all the settings for specified section except # for log file and log level which are applied separately to # ensure they are set as soon as possible. @@ -452,17 +540,37 @@ def _process_configuration(section): "distributed_tracing.sampler.remote_parent_sampled", "get", ) + _process_dt_sampler_setting( + section, + "distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target", + "getint", + ) _process_dt_setting( section, "distributed_tracing.sampler.full_granularity.remote_parent_not_sampled", "distributed_tracing.sampler.remote_parent_not_sampled", "get", ) + _process_dt_sampler_setting( + section, + "distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target", + "getint", + ) _process_setting(section, "distributed_tracing.sampler.full_granularity.enabled", "getboolean", None) _process_setting(section, "distributed_tracing.sampler.partial_granularity.enabled", "getboolean", None) _process_setting(section, "distributed_tracing.sampler.partial_granularity.type", "get", None) - _process_setting(section, "distributed_tracing.sampler.partial_granularity.remote_parent_sampled", "get", None) - _process_setting(section, "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled", "get", None) + _process_dt_hidden_setting(section, "distributed_tracing.sampler.partial_granularity.remote_parent_sampled", "get") + _process_dt_sampler_setting( + section, + "distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target", + "getint", + ) + _process_dt_hidden_setting(section, "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled", "get") + _process_dt_sampler_setting( + section, + "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target", + "getint", + ) _process_setting(section, "span_events.enabled", "getboolean", None) _process_setting(section, "span_events.max_samples_stored", "getint", None) _process_setting(section, "span_events.attributes.enabled", "getboolean", None) diff --git a/newrelic/core/config.py b/newrelic/core/config.py index fe5c9b5872..0e5194a779 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -338,10 +338,44 @@ class DistributedTracingSamplerSettings(Settings): class DistributedTracingSamplerFullGranularitySettings(Settings): + _remote_parent_sampled = "default" + _remote_parent_not_sampled = "default" + + +class DistributedTracingSamplerFullGranularityRemoteParentSampledSettings: + pass + + +class DistributedTracingSamplerFullGranularityRemoteParentSampledAdaptiveSettings: + pass + + +class DistributedTracingSamplerFullGranularityRemoteParentNotSampledSettings: + pass + + +class DistributedTracingSamplerFullGranularityRemoteParentNotSampledAdaptiveSettings: pass class DistributedTracingSamplerPartialGranularitySettings(Settings): + _remote_parent_sampled = "default" + _remote_parent_not_sampled = "default" + + +class DistributedTracingSamplerPartialGranularityRemoteParentSampledSettings: + pass + + +class DistributedTracingSamplerPartialGranularityRemoteParentSampledAdaptiveSettings: + pass + + +class DistributedTracingSamplerPartialGranularityRemoteParentNotSampledSettings: + pass + + +class DistributedTracingSamplerPartialGranularityRemoteParentNotSampledAdaptiveSettings: pass @@ -516,7 +550,15 @@ class EventHarvestConfigHarvestLimitSettings(Settings): _settings.distributed_tracing = DistributedTracingSettings() _settings.distributed_tracing.sampler = DistributedTracingSamplerSettings() _settings.distributed_tracing.sampler.full_granularity = DistributedTracingSamplerFullGranularitySettings() +_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled = DistributedTracingSamplerFullGranularityRemoteParentSampledSettings() +_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive = DistributedTracingSamplerFullGranularityRemoteParentSampledAdaptiveSettings() +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled = DistributedTracingSamplerFullGranularityRemoteParentNotSampledSettings() +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive = DistributedTracingSamplerFullGranularityRemoteParentNotSampledAdaptiveSettings() _settings.distributed_tracing.sampler.partial_granularity = DistributedTracingSamplerPartialGranularitySettings() +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled = DistributedTracingSamplerPartialGranularityRemoteParentSampledSettings() +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive = DistributedTracingSamplerPartialGranularityRemoteParentSampledAdaptiveSettings() +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled = DistributedTracingSamplerPartialGranularityRemoteParentNotSampledSettings() +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive = DistributedTracingSamplerPartialGranularityRemoteParentNotSampledAdaptiveSettings() _settings.error_collector = ErrorCollectorSettings() _settings.error_collector.attributes = ErrorCollectorAttributesSettings() _settings.event_harvest_config = EventHarvestConfigSettings() @@ -561,6 +603,8 @@ class EventHarvestConfigHarvestLimitSettings(Settings): def _environ_as_int(name, default=0): val = os.environ.get(name, default) try: + if default is None and val is None: + return None return int(val) except ValueError: return default @@ -858,24 +902,36 @@ def default_otlp_host(host): _settings.distributed_tracing.sampler.full_granularity.enabled = _environ_as_bool( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_ENABLED", default=True ) -_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled = os.environ.get( +_settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED", None ) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_SAMPLED", "default") -_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled = os.environ.get( +_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target = _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +) +_settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", None ) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_NOT_SAMPLED", "default") +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target = _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +) _settings.distributed_tracing.sampler.partial_granularity.enabled = _environ_as_bool( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_ENABLED", default=False ) _settings.distributed_tracing.sampler.partial_granularity.type = os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_TYPE", "essential" ) -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled = os.environ.get( +_settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED", "default" ) -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled = os.environ.get( +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target = _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +) +_settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", "default" ) +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target = _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +) _settings.distributed_tracing.exclude_newrelic_header = False _settings.span_events.enabled = _environ_as_bool("NEW_RELIC_SPAN_EVENTS_ENABLED", default=True) _settings.event_harvest_config.harvest_limits.span_event_data = _environ_as_int( diff --git a/tests/agent_features/test_distributed_tracing.py b/tests/agent_features/test_distributed_tracing.py index 33433ff7c9..ec2bb3f966 100644 --- a/tests/agent_features/test_distributed_tracing.py +++ b/tests/agent_features/test_distributed_tracing.py @@ -586,8 +586,8 @@ def test_distributed_trace_remote_parent_sampling_decision_full_granularity( test_settings = _override_settings.copy() test_settings.update( { - "distributed_tracing.sampler.full_granularity.remote_parent_sampled": remote_parent_sampled_setting, - "distributed_tracing.sampler.full_granularity.remote_parent_not_sampled": remote_parent_not_sampled_setting, + "distributed_tracing.sampler.full_granularity._remote_parent_sampled": remote_parent_sampled_setting, + "distributed_tracing.sampler.full_granularity._remote_parent_not_sampled": remote_parent_not_sampled_setting, "span_events.enabled": True, } ) @@ -677,8 +677,8 @@ def test_distributed_trace_remote_parent_sampling_decision_partial_granularity( { "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": remote_parent_sampled_setting, - "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled": remote_parent_not_sampled_setting, + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": remote_parent_sampled_setting, + "distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled": remote_parent_not_sampled_setting, "span_events.enabled": True, } ) @@ -745,8 +745,8 @@ def test_distributed_trace_remote_parent_sampling_decision_between_full_and_part { "distributed_tracing.sampler.full_granularity.enabled": full_granularity_enabled, "distributed_tracing.sampler.partial_granularity.enabled": partial_granularity_enabled, - "distributed_tracing.sampler.full_granularity.remote_parent_sampled": full_granularity_remote_parent_sampled_setting, - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": partial_granularity_remote_parent_sampled_setting, + "distributed_tracing.sampler.full_granularity._remote_parent_sampled": full_granularity_remote_parent_sampled_setting, + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": partial_granularity_remote_parent_sampled_setting, "span_events.enabled": True, } ) @@ -815,7 +815,7 @@ def _test(): "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, "distributed_tracing.sampler.partial_granularity.type": "compact", - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": "always_on", + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "always_on", "span_events.enabled": True, } )(_test) @@ -868,7 +868,7 @@ def _test(): "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, "distributed_tracing.sampler.partial_granularity.type": "compact", - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": "always_on", + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "always_on", "span_events.enabled": True, } )(_test) @@ -918,7 +918,7 @@ def _test(): "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, "distributed_tracing.sampler.partial_granularity.type": "compact", - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": "always_on", + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "always_on", "span_events.enabled": True, } )(_test) @@ -972,7 +972,7 @@ def _test(): "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, "distributed_tracing.sampler.partial_granularity.type": "reduced", - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": "always_on", + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "always_on", "span_events.enabled": True, } )(_test) @@ -1026,7 +1026,7 @@ def _test(): "distributed_tracing.sampler.full_granularity.enabled": False, "distributed_tracing.sampler.partial_granularity.enabled": True, "distributed_tracing.sampler.partial_granularity.type": "essential", - "distributed_tracing.sampler.partial_granularity.remote_parent_sampled": "always_on", + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "always_on", "span_events.enabled": True, } )(_test) diff --git a/tests/agent_unittests/test_distributed_tracing_settings.py b/tests/agent_unittests/test_distributed_tracing_settings.py index 3668cfbe32..4980d8dc2e 100644 --- a/tests/agent_unittests/test_distributed_tracing_settings.py +++ b/tests/agent_unittests/test_distributed_tracing_settings.py @@ -26,6 +26,46 @@ distributed_tracing.exclude_newrelic_header = true """ +INI_FILE_FULL_GRAN_CONFLICTS = b""" +[newrelic] +distributed_tracing.sampler.remote_parent_sampled = default +distributed_tracing.sampler.remote_parent_not_sampled = default +distributed_tracing.sampler.full_granularity.remote_parent_sampled = always_on +distributed_tracing.sampler.full_granularity.remote_parent_not_sampled = always_off +""" + +INI_FILE_FULL_GRAN_CONFLICTS_ADAPTIVE = b""" +[newrelic] +distributed_tracing.sampler.remote_parent_sampled = always_on +distributed_tracing.sampler.remote_parent_not_sampled = always_off +distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target = 10 +distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target = 20 +""" + +INI_FILE_FULL_GRAN_MULTIPLE_SAMPLERS = b""" +[newrelic] +distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target = 10 +distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target = 20 +distributed_tracing.sampler.full_granularity.remote_parent_sampled.trace_id_ratio_based.sampling_target = 10 +distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.trace_id_ratio_based.sampling_target = 20 +""" + +INI_FILE_PARTIAL_GRAN_CONFLICTS_ADAPTIVE = b""" +[newrelic] +distributed_tracing.sampler.partial_granularity.remote_parent_sampled = always_on +distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled = always_off +distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target = 10 +distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target = 20 +""" + +INI_FILE_PARTIAL_GRAN_MULTIPLE_SAMPLERS = b""" +[newrelic] +distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target = 10 +distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target = 20 +distributed_tracing.sampler.partial_granularity.remote_parent_sampled.trace_id_ratio_based.sampling_target = 10 +distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.trace_id_ratio_based.sampling_target = 20 +""" + # Tests for loading settings and testing for values precedence @pytest.mark.parametrize("ini,env,expected_format", ((INI_FILE_EMPTY, {}, False), (INI_FILE_W3C, {}, True))) @@ -35,9 +75,33 @@ def test_distributed_trace_setings(ini, env, expected_format, global_settings): @pytest.mark.parametrize( - "ini,env", + "ini,env,expected", ( - ( + ( # Defaults to adaptive (default) sampler. + INI_FILE_EMPTY, + {}, + ("default", "default", None, None), + ), + ( # More specific full granularity path overrides less specific path in ini file. + INI_FILE_FULL_GRAN_CONFLICTS, + {}, + ("always_on", "always_off", None, None), + ), + ( # More specific sampler path overrides less specific path in ini file. + INI_FILE_FULL_GRAN_CONFLICTS_ADAPTIVE, + {}, + ("adaptive", "adaptive", 10, 20), + ), + ( # ini file configuration takes precedence over env vars. + INI_FILE_FULL_GRAN_CONFLICTS_ADAPTIVE, + { + "NEW_RELIC_ENABLED": "true", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "50", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "30", + }, + ("adaptive", "adaptive", 10, 20), + ), + ( # More specific full granularity path overrides less specific path in env vars. INI_FILE_EMPTY, { "NEW_RELIC_ENABLED": "true", @@ -46,21 +110,104 @@ def test_distributed_trace_setings(ini, env, expected_format, global_settings): "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED": "always_on", "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED": "always_off", }, + ("always_on", "always_off", None, None), + ), + ( # Simple configuration works. + INI_FILE_EMPTY, + { + "NEW_RELIC_ENABLED": "true", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_SAMPLED": "always_on", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_NOT_SAMPLED": "always_off", + }, + ("always_on", "always_off", None, None), ), - ( + ( # More specific sampler path overrides less specific path in env vars. INI_FILE_EMPTY, { "NEW_RELIC_ENABLED": "true", "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_SAMPLED": "always_on", "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_NOT_SAMPLED": "always_off", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "20", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "20", }, + ("adaptive", "adaptive", 20, 20), + ), + ( # Ignores other unknown samplers. + INI_FILE_FULL_GRAN_MULTIPLE_SAMPLERS, + {}, + ("adaptive", "adaptive", 10, 20), + ), + ), +) +def test_full_granularity_precedence(ini, env, global_settings, expected): + settings = global_settings() + + app_settings = finalize_application_settings(settings=settings) + + assert app_settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled == expected[0] + assert app_settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled == expected[1] + assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target == expected[2] + assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target == expected[3] + + + +@pytest.mark.parametrize( + "ini,env,expected", + ( + ( # Defaults to adaptive (default) sampler. + INI_FILE_EMPTY, + {}, + ("default", "default", None, None), + ), + ( # More specific sampler path overrides less specific path in ini file. + INI_FILE_PARTIAL_GRAN_CONFLICTS_ADAPTIVE, + {}, + ("adaptive", "adaptive", 10, 20), + ), + ( # ini config takes precedence over env vars. + INI_FILE_PARTIAL_GRAN_CONFLICTS_ADAPTIVE, + { + "NEW_RELIC_ENABLED": "true", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED": "always_on", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED": "always_off", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "20", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "30", + }, + ("adaptive", "adaptive", 10, 20), + ), + ( # Simple configuration works. + INI_FILE_EMPTY, + { + "NEW_RELIC_ENABLED": "true", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED": "always_on", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED": "always_off", + }, + ("always_on", "always_off", None, None), + ), + ( # More specific sampler path overrides less specific path in env vars. + INI_FILE_EMPTY, + { + "NEW_RELIC_ENABLED": "true", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED": "always_on", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED": "always_off", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "10", + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET": "20", + }, + ("adaptive", "adaptive", 10, 20), + ), + ( # Ignores other unknown samplers. + INI_FILE_PARTIAL_GRAN_MULTIPLE_SAMPLERS, + {}, + ("adaptive", "adaptive", 10, 20), ), ), ) -def test_full_granularity_precedence(ini, env, global_settings): +def test_partial_granularity_precedence(ini, env, global_settings, expected): settings = global_settings() app_settings = finalize_application_settings(settings=settings) - assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled == "always_on" - assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled == "always_off" + assert app_settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled == expected[0] + assert app_settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled == expected[1] + assert app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target == expected[2] + assert app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target == expected[3] From 090b4c4d496ad725d655797ef8853ff0422f61e3 Mon Sep 17 00:00:00 2001 From: Hannah Stepanek Date: Fri, 14 Nov 2025 18:40:43 -0800 Subject: [PATCH 17/21] Add adaptive sampler instances to SamplerProxy --- newrelic/config.py | 10 +- newrelic/core/config.py | 103 +++++++++++----- newrelic/core/samplers/sampler_proxy.py | 35 +++++- .../test_distributed_tracing.py | 110 ++++++++++++++++++ .../test_distributed_tracing_settings.py | 21 +++- 5 files changed, 241 insertions(+), 38 deletions(-) diff --git a/newrelic/config.py b/newrelic/config.py index 96c41c2271..c82e1c4494 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -406,6 +406,7 @@ def _process_dt_hidden_setting(section, option, getter): except Exception: _raise_configuration_error(section, option) + def _process_dt_sampler_setting(section, option, getter): try: # The type of a value is dictated by the getter @@ -430,7 +431,6 @@ def _process_dt_sampler_setting(section, option, getter): target = getattr(target, fields[0]) fields = fields[1].split(".", 1) - # Cache the configuration so can be dumped out to # log file when whole main configuration has been # processed. This ensures that the log file and log @@ -541,9 +541,7 @@ def _process_configuration(section): "get", ) _process_dt_sampler_setting( - section, - "distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target", - "getint", + section, "distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target", "getint" ) _process_dt_setting( section, @@ -565,7 +563,9 @@ def _process_configuration(section): "distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target", "getint", ) - _process_dt_hidden_setting(section, "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled", "get") + _process_dt_hidden_setting( + section, "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled", "get" + ) _process_dt_sampler_setting( section, "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target", diff --git a/newrelic/core/config.py b/newrelic/core/config.py index 0e5194a779..230e8067e5 100644 --- a/newrelic/core/config.py +++ b/newrelic/core/config.py @@ -550,15 +550,31 @@ class EventHarvestConfigHarvestLimitSettings(Settings): _settings.distributed_tracing = DistributedTracingSettings() _settings.distributed_tracing.sampler = DistributedTracingSamplerSettings() _settings.distributed_tracing.sampler.full_granularity = DistributedTracingSamplerFullGranularitySettings() -_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled = DistributedTracingSamplerFullGranularityRemoteParentSampledSettings() -_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive = DistributedTracingSamplerFullGranularityRemoteParentSampledAdaptiveSettings() -_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled = DistributedTracingSamplerFullGranularityRemoteParentNotSampledSettings() -_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive = DistributedTracingSamplerFullGranularityRemoteParentNotSampledAdaptiveSettings() +_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled = ( + DistributedTracingSamplerFullGranularityRemoteParentSampledSettings() +) +_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive = ( + DistributedTracingSamplerFullGranularityRemoteParentSampledAdaptiveSettings() +) +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled = ( + DistributedTracingSamplerFullGranularityRemoteParentNotSampledSettings() +) +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive = ( + DistributedTracingSamplerFullGranularityRemoteParentNotSampledAdaptiveSettings() +) _settings.distributed_tracing.sampler.partial_granularity = DistributedTracingSamplerPartialGranularitySettings() -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled = DistributedTracingSamplerPartialGranularityRemoteParentSampledSettings() -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive = DistributedTracingSamplerPartialGranularityRemoteParentSampledAdaptiveSettings() -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled = DistributedTracingSamplerPartialGranularityRemoteParentNotSampledSettings() -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive = DistributedTracingSamplerPartialGranularityRemoteParentNotSampledAdaptiveSettings() +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled = ( + DistributedTracingSamplerPartialGranularityRemoteParentSampledSettings() +) +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive = ( + DistributedTracingSamplerPartialGranularityRemoteParentSampledAdaptiveSettings() +) +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled = ( + DistributedTracingSamplerPartialGranularityRemoteParentNotSampledSettings() +) +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive = ( + DistributedTracingSamplerPartialGranularityRemoteParentNotSampledAdaptiveSettings() +) _settings.error_collector = ErrorCollectorSettings() _settings.error_collector.attributes = ErrorCollectorAttributesSettings() _settings.event_harvest_config = EventHarvestConfigSettings() @@ -902,17 +918,38 @@ def default_otlp_host(host): _settings.distributed_tracing.sampler.full_granularity.enabled = _environ_as_bool( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_ENABLED", default=True ) -_settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED", None -) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_SAMPLED", "default") +_settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled = ( + ( + "adaptive" + if os.environ.get( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", + None, + ) + else None + ) + or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED", None) + or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_SAMPLED", "default") +) _settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target = _environ_as_int( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None ) -_settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", None -) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_NOT_SAMPLED", "default") -_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target = _environ_as_int( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +_settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled = ( + ( + "adaptive" + if os.environ.get( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", + None, + ) + else None + ) + or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", None) + or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_REMOTE_PARENT_NOT_SAMPLED", "default") +) +_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target = ( + _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_FULL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", + None, + ) ) _settings.distributed_tracing.sampler.partial_granularity.enabled = _environ_as_bool( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_ENABLED", default=False @@ -920,17 +957,31 @@ def default_otlp_host(host): _settings.distributed_tracing.sampler.partial_granularity.type = os.environ.get( "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_TYPE", "essential" ) -_settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED", "default" -) -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target = _environ_as_int( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None -) -_settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled = ("adaptive" if os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None) else None) or os.environ.get( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", "default" +_settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled = ( + "adaptive" + if os.environ.get( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None + ) + else None +) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED", "default") +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target = ( + _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None + ) ) -_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target = _environ_as_int( - "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", None +_settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled = ( + "adaptive" + if os.environ.get( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", + None, + ) + else None +) or os.environ.get("NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED", "default") +_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target = ( + _environ_as_int( + "NEW_RELIC_DISTRIBUTED_TRACING_SAMPLER_PARTIAL_GRANULARITY_REMOTE_PARENT_NOT_SAMPLED_ADAPTIVE_SAMPLING_TARGET", + None, + ) ) _settings.distributed_tracing.exclude_newrelic_header = False _settings.span_events.enabled = _environ_as_bool("NEW_RELIC_SPAN_EVENTS_ENABLED", default=True) diff --git a/newrelic/core/samplers/sampler_proxy.py b/newrelic/core/samplers/sampler_proxy.py index 2e8b7cf87f..70cb706267 100644 --- a/newrelic/core/samplers/sampler_proxy.py +++ b/newrelic/core/samplers/sampler_proxy.py @@ -21,10 +21,41 @@ def __init__(self, settings): else: sampling_target_period = settings.sampling_target_period_in_seconds adaptive_sampler = AdaptiveSampler(settings.sampling_target, sampling_target_period) - self._samplers = [adaptive_sampler] + self._samplers = {"global": adaptive_sampler} + # Add adaptive sampler instances for each config section if configured. + self.add_adaptive_sampler( + (True, 1), + settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target, + sampling_target_period, + ) + self.add_adaptive_sampler( + (True, 2), + settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target, + sampling_target_period, + ) + self.add_adaptive_sampler( + (False, 1), + settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target, + sampling_target_period, + ) + self.add_adaptive_sampler( + (False, 2), + settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target, + sampling_target_period, + ) + + def add_adaptive_sampler(self, key, sampling_target, sampling_target_period): + """ + Add an adaptive sampler instance to self._samplers if the sampling_target is specified. + """ + if sampling_target: + adaptive_sampler = AdaptiveSampler(sampling_target, sampling_target_period) + self._samplers[key] = adaptive_sampler def get_sampler(self, full_granularity, section): - return self._samplers[0] + # Return the sampler instance for the given config section. + # If no instance is present, return the global adaptive sampler instance instead. + return self._samplers.get((full_granularity, section)) or self._samplers["global"] def compute_sampled(self, full_granularity, section, *args, **kwargs): """ diff --git a/tests/agent_features/test_distributed_tracing.py b/tests/agent_features/test_distributed_tracing.py index ec2bb3f966..0a64a4e97a 100644 --- a/tests/agent_features/test_distributed_tracing.py +++ b/tests/agent_features/test_distributed_tracing.py @@ -26,6 +26,7 @@ from testing_support.validators.validate_transaction_event_attributes import validate_transaction_event_attributes from testing_support.validators.validate_transaction_metrics import validate_transaction_metrics +from newrelic.api.application import application_instance from newrelic.api.function_trace import function_trace from newrelic.common.object_wrapper import function_wrapper, transient_function_wrapper @@ -1032,3 +1033,112 @@ def _test(): )(_test) _test() + + +@pytest.mark.parametrize( + "dt_settings,dt_headers,expected_sampling_instance_called,expected_adaptive_computed_count,expected_adaptive_sampled_count,expected_adaptive_sampling_target", + ( + ( + { + "distributed_tracing.sampler.full_granularity.enabled": False, + "distributed_tracing.sampler.partial_granularity.enabled": True, + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "default", + "distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target": 5, + "distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled": "default", + "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target": 6, + }, + {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"}, + (False, 1), + 1, + 1, + 5, + ), + ( + { + "distributed_tracing.sampler.full_granularity.enabled": False, + "distributed_tracing.sampler.partial_granularity.enabled": True, + "distributed_tracing.sampler.partial_granularity._remote_parent_sampled": "default", + "distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target": 5, + "distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled": "default", + "distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target": 6, + }, + {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-00"}, + (False, 2), + 1, + 1, + 6, + ), + ( + { + "distributed_tracing.sampler.full_granularity.enabled": True, + "distributed_tracing.sampler.partial_granularity.enabled": False, + "distributed_tracing.sampler.full_granularity._remote_parent_sampled": "default", + "distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target": 5, + "distributed_tracing.sampler.full_granularity._remote_parent_not_sampled": "default", + "distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target": 6, + }, + {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-01"}, + (True, 1), + 1, + 1, + 5, + ), + ( + { + "distributed_tracing.sampler.full_granularity.enabled": True, + "distributed_tracing.sampler.partial_granularity.enabled": False, + "distributed_tracing.sampler.full_granularity._remote_parent_sampled": "default", + "distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target": 5, + "distributed_tracing.sampler.full_granularity._remote_parent_not_sampled": "default", + "distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target": 6, + }, + {"traceparent": "00-0af7651916cd43dd8448eb211c80319c-00f067aa0ba902b7-00"}, + (True, 2), + 1, + 1, + 6, + ), + ), +) +def test_distributed_trace_uses_sampling_instance( + dt_settings, + dt_headers, + expected_sampling_instance_called, + expected_adaptive_computed_count, + expected_adaptive_sampled_count, + expected_adaptive_sampling_target, +): + test_settings = _override_settings.copy() + test_settings.update(dt_settings) + function_called_decorator = validate_function_called( + "newrelic.core.samplers.adaptive_sampler", "AdaptiveSampler.compute_sampled" + ) + + @function_called_decorator + @override_application_settings(test_settings) + @background_task(name="test_distributed_trace_attributes") + def _test(): + txn = current_transaction() + application = txn._application._agent._applications.get(txn.settings.app_name) + # Re-initialize sampler proxy after overriding settings. + application.sampler.__init__(txn.settings) + + accept_distributed_trace_headers(dt_headers) + # Explicitly call this so we can assert sampling decision during the transaction + # as opposed to after it ends and we lose the application context. + txn._make_sampling_decision() + + assert ( + application.sampler._samplers[expected_sampling_instance_called].computed_count + == expected_adaptive_computed_count + ) + assert ( + application.sampler._samplers[expected_sampling_instance_called].sampled_count + == expected_adaptive_sampled_count + ) + assert ( + application.sampler._samplers[expected_sampling_instance_called].sampling_target + == expected_adaptive_sampling_target + ) + + _test() diff --git a/tests/agent_unittests/test_distributed_tracing_settings.py b/tests/agent_unittests/test_distributed_tracing_settings.py index 4980d8dc2e..8b0402b47c 100644 --- a/tests/agent_unittests/test_distributed_tracing_settings.py +++ b/tests/agent_unittests/test_distributed_tracing_settings.py @@ -146,9 +146,14 @@ def test_full_granularity_precedence(ini, env, global_settings, expected): assert app_settings.distributed_tracing.sampler.full_granularity._remote_parent_sampled == expected[0] assert app_settings.distributed_tracing.sampler.full_granularity._remote_parent_not_sampled == expected[1] - assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target == expected[2] - assert app_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target == expected[3] - + assert ( + app_settings.distributed_tracing.sampler.full_granularity.remote_parent_sampled.adaptive.sampling_target + == expected[2] + ) + assert ( + app_settings.distributed_tracing.sampler.full_granularity.remote_parent_not_sampled.adaptive.sampling_target + == expected[3] + ) @pytest.mark.parametrize( @@ -209,5 +214,11 @@ def test_partial_granularity_precedence(ini, env, global_settings, expected): assert app_settings.distributed_tracing.sampler.partial_granularity._remote_parent_sampled == expected[0] assert app_settings.distributed_tracing.sampler.partial_granularity._remote_parent_not_sampled == expected[1] - assert app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target == expected[2] - assert app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target == expected[3] + assert ( + app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_sampled.adaptive.sampling_target + == expected[2] + ) + assert ( + app_settings.distributed_tracing.sampler.partial_granularity.remote_parent_not_sampled.adaptive.sampling_target + == expected[3] + ) From 41939912cdde3bd4f6506542e1333678ec4885eb Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:54:25 -0800 Subject: [PATCH 18/21] Fix instability in CI caused by health check tests (#1584) --- .../test_agent_control_health_check.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/agent_features/test_agent_control_health_check.py b/tests/agent_features/test_agent_control_health_check.py index e12f3a07f0..84058a1b28 100644 --- a/tests/agent_features/test_agent_control_health_check.py +++ b/tests/agent_features/test_agent_control_health_check.py @@ -38,7 +38,7 @@ def get_health_file_contents(tmp_path): return contents -@pytest.fixture(scope="module", autouse=True) +@pytest.fixture(autouse=True) def restore_settings_fixture(): # Backup settings from before this test file runs original_settings = global_settings() @@ -51,6 +51,10 @@ def restore_settings_fixture(): original_settings.__dict__.clear() original_settings.__dict__.update(backup) + # Re-initialize the agent to restore the settings + _reset_configuration_done() + initialize() + @pytest.mark.parametrize("file_uri", ["", "file://", "/test/dir", "foo:/test/dir"]) def test_invalid_file_directory_supplied(monkeypatch, file_uri): @@ -155,10 +159,18 @@ def test_no_override_on_unhealthy_shutdown(monkeypatch, tmp_path): def test_health_check_running_threads(monkeypatch, tmp_path): - running_threads = threading.enumerate() - # Only the main thread should be running since not agent control env vars are set - assert len(running_threads) == 1 + # If the Activate-Session thread is still active, give it time to close before we proceed + timeout = 30.0 + while len(threading.enumerate()) != 1 and timeout > 0: + time.sleep(0.1) + timeout -= 0.1 + # Only the main thread should be running since no agent control env vars are set + assert len(threading.enumerate()) == 1, ( + f"Expected only the main thread to be running before the test starts. Got: {threading.enumerate()}" + ) + + # Setup expected env vars to run agent control health check monkeypatch.setenv("NEW_RELIC_AGENT_CONTROL_ENABLED", "True") file_path = tmp_path.as_uri() monkeypatch.setenv("NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION", file_path) @@ -180,6 +192,7 @@ def test_proxy_error_status(monkeypatch, tmp_path): file_path = tmp_path.as_uri() monkeypatch.setenv("NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION", file_path) + # Re-initialize the agent to allow the health check thread to start _reset_configuration_done() initialize() @@ -209,6 +222,7 @@ def test_multiple_activations_running_threads(monkeypatch, tmp_path): file_path = tmp_path.as_uri() monkeypatch.setenv("NEW_RELIC_AGENT_CONTROL_HEALTH_DELIVERY_LOCATION", file_path) + # Re-initialize the agent to allow the health check thread to start and assert that it did _reset_configuration_done() initialize() From 32215b90c906ee43ae5e03379cd87a3e9a80ff8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:20:03 -0800 Subject: [PATCH 19/21] Bump the github_actions group across 1 directory with 5 updates (#1582) Bumps the github_actions group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `5.0.0` | `5.0.1` | | [docker/metadata-action](https://github.com/docker/metadata-action) | `5.8.0` | `5.9.0` | | [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3.6.0` | `3.7.0` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `7.1.2` | `7.1.3` | | [github/codeql-action](https://github.com/github/codeql-action) | `4.31.2` | `4.31.3` | Updates `actions/checkout` from 5.0.0 to 5.0.1 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/08c6903cd8c0fde910a37f88322edcfb5dd907a8...93cb6efe18208431cddfb8368fd83d5badbf9bfd) Updates `docker/metadata-action` from 5.8.0 to 5.9.0 - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/c1e51972afc2121e065aed6d45c65596fe445f3f...318604b99e75e41977312d83839a89be02ca4893) Updates `docker/setup-qemu-action` from 3.6.0 to 3.7.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/29109295f81e9208d7d86ff1c6c12d2833863392...c7c53464625b32c7a7e944ae62b3e17d2b600130) Updates `astral-sh/setup-uv` from 7.1.2 to 7.1.3 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41...5a7eac68fb9809dea845d802897dc5c723910fa3) Updates `github/codeql-action` from 4.31.2 to 4.31.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/0499de31b99561a6d14a36a5f662c2a54f91beee...014f16e7ab1402f30e7c3329d33797e7948572db) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions - dependency-name: docker/metadata-action dependency-version: 5.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github_actions - dependency-name: docker/setup-qemu-action dependency-version: 3.7.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github_actions - dependency-name: astral-sh/setup-uv dependency-version: 7.1.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions - dependency-name: github/codeql-action dependency-version: 4.31.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github_actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .github/workflows/addlicense.yml | 2 +- .github/workflows/benchmarks.yml | 2 +- .github/workflows/build-ci-image.yml | 6 +-- .github/workflows/deploy.yml | 6 +-- .github/workflows/mega-linter.yml | 2 +- .github/workflows/tests.yml | 58 ++++++++++++++-------------- .github/workflows/trivy.yml | 4 +- 7 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/workflows/addlicense.yml b/.github/workflows/addlicense.yml index 8d66691ff7..83e5b29ef4 100644 --- a/.github/workflows/addlicense.yml +++ b/.github/workflows/addlicense.yml @@ -39,7 +39,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 513e467f29..a65695e7c4 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -38,7 +38,7 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/build-ci-image.yml b/.github/workflows/build-ci-image.yml index ab183f48a2..061233b6dd 100644 --- a/.github/workflows/build-ci-image.yml +++ b/.github/workflows/build-ci-image.yml @@ -43,7 +43,7 @@ jobs: name: Docker Build ${{ matrix.platform }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: persist-credentials: false fetch-depth: 0 @@ -60,7 +60,7 @@ jobs: - name: Generate Docker Metadata (Tags and Labels) id: meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # 5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # 5.9.0 with: images: ghcr.io/${{ steps.image-name.outputs.IMAGE_NAME }} flavor: | @@ -139,7 +139,7 @@ jobs: - name: Generate Docker Metadata (Tags and Labels) id: meta - uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # 5.8.0 + uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # 5.9.0 with: images: ghcr.io/${{ steps.image-name.outputs.IMAGE_NAME }} flavor: | diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8b469eaacb..af4739f2a3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -69,14 +69,14 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: persist-credentials: false fetch-depth: 0 - name: Setup QEMU if: runner.os == 'Linux' - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # 3.6.0 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # 3.7.0 with: platforms: arm64 @@ -109,7 +109,7 @@ jobs: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: persist-credentials: false fetch-depth: 0 diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index 8f74866d43..0f869f3b58 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -45,7 +45,7 @@ jobs: steps: # Git Checkout - name: Checkout Code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} fetch-depth: 0 # Required for pushing commits to PRs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9ef7b2d4e..9e47302bd4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -93,7 +93,7 @@ jobs: - tests steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 with: python-version: "3.13" @@ -127,7 +127,7 @@ jobs: - tests steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # 6.0.0 with: python-version: "3.13" @@ -166,7 +166,7 @@ jobs: --add-host=host.docker.internal:host-gateway timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -231,7 +231,7 @@ jobs: --add-host=host.docker.internal:host-gateway timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -294,14 +294,14 @@ jobs: runs-on: windows-2025 timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | git fetch --tags origin - name: Install uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 + uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # 7.1.3 - name: Install Python run: | @@ -363,14 +363,14 @@ jobs: runs-on: windows-11-arm timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | git fetch --tags origin - name: Install uv - uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # 7.1.2 + uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # 7.1.3 - name: Install Python run: | @@ -443,7 +443,7 @@ jobs: --add-host=host.docker.internal:host-gateway timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -526,7 +526,7 @@ jobs: --health-retries 10 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -606,7 +606,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -687,7 +687,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -772,7 +772,7 @@ jobs: # from every being executed as bash commands. steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -837,7 +837,7 @@ jobs: --add-host=host.docker.internal:host-gateway timeout-minutes: 30 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -927,7 +927,7 @@ jobs: KAFKA_CFG_INTER_BROKER_LISTENER_NAME: L3 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1005,7 +1005,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1083,7 +1083,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1161,7 +1161,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1244,7 +1244,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1327,7 +1327,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1406,7 +1406,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1487,7 +1487,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1567,7 +1567,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1647,7 +1647,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1726,7 +1726,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1804,7 +1804,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -1923,7 +1923,7 @@ jobs: --add-host=host.docker.internal:host-gateway steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -2003,7 +2003,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | @@ -2081,7 +2081,7 @@ jobs: --health-retries 5 steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 - name: Fetch git tags run: | diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index c373a38bb1..e4b0e38c9c 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -32,7 +32,7 @@ jobs: steps: # Git Checkout - name: Checkout Code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # 5.0.0 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # 5.0.1 with: token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -61,6 +61,6 @@ jobs: - name: Upload Trivy scan results to GitHub Security tab if: ${{ github.event_name == 'schedule' }} - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # 4.31.2 + uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # 4.31.3 with: sarif_file: "trivy-results.sarif" From f59f52cd5c4856c57670ff8c4db40d723553ed96 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei <84813886+lrafeei@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:54:54 -0800 Subject: [PATCH 20/21] Asyncio loop_factory fix (#1576) * Runner instrumentation in asyncio * Clean up asyncio instrumentation * Add asyncio tests for loop_factory * Modify uvicorn test for loop_factory * Fix linter errors * [MegaLinter] Apply linters fixes * Apply suggestions from code review --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Tim Pansino --- newrelic/config.py | 6 +- newrelic/hooks/coroutines_asyncio.py | 61 +++++++-- tests/adapter_uvicorn/test_uvicorn.py | 6 +- .../test_context_propagation.py | 119 +++++++++++++++++- tox.ini | 8 +- 5 files changed, 176 insertions(+), 24 deletions(-) diff --git a/newrelic/config.py b/newrelic/config.py index 21ce996f6c..c2b7b5c2d6 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -2084,6 +2084,10 @@ def _process_module_builtin_defaults(): "asyncio.base_events", "newrelic.hooks.coroutines_asyncio", "instrument_asyncio_base_events" ) + _process_module_definition("asyncio.events", "newrelic.hooks.coroutines_asyncio", "instrument_asyncio_events") + + _process_module_definition("asyncio.runners", "newrelic.hooks.coroutines_asyncio", "instrument_asyncio_runners") + _process_module_definition( "langchain_core.runnables.base", "newrelic.hooks.mlmodel_langchain", @@ -2671,8 +2675,6 @@ def _process_module_builtin_defaults(): "langchain_core.callbacks.manager", "newrelic.hooks.mlmodel_langchain", "instrument_langchain_callbacks_manager" ) - _process_module_definition("asyncio.events", "newrelic.hooks.coroutines_asyncio", "instrument_asyncio_events") - _process_module_definition("asgiref.sync", "newrelic.hooks.adapter_asgiref", "instrument_asgiref_sync") _process_module_definition( diff --git a/newrelic/hooks/coroutines_asyncio.py b/newrelic/hooks/coroutines_asyncio.py index 41fc776595..6f862d52dd 100644 --- a/newrelic/hooks/coroutines_asyncio.py +++ b/newrelic/hooks/coroutines_asyncio.py @@ -16,36 +16,73 @@ from newrelic.core.trace_cache import trace_cache -def remove_from_cache(task): +def remove_from_cache_callback(task): cache = trace_cache() cache.task_stop(task) -def propagate_task_context(task): +def wrap_create_task(task): trace_cache().task_start(task) - task.add_done_callback(remove_from_cache) + task.add_done_callback(remove_from_cache_callback) return task -def _bind_loop(loop, *args, **kwargs): +def _instrument_event_loop(loop): + if loop and hasattr(loop, "create_task") and not hasattr(loop.create_task, "__wrapped__"): + wrap_out_function(loop, "create_task", wrap_create_task) + + +def _bind_set_event_loop(loop, *args, **kwargs): return loop -def wrap_create_task(wrapped, instance, args, kwargs): - loop = _bind_loop(*args, **kwargs) +def wrap_set_event_loop(wrapped, instance, args, kwargs): + loop = _bind_set_event_loop(*args, **kwargs) - if loop and not hasattr(loop.create_task, "__wrapped__"): - wrap_out_function(loop, "create_task", propagate_task_context) + _instrument_event_loop(loop) return wrapped(*args, **kwargs) +def wrap__lazy_init(wrapped, instance, args, kwargs): + result = wrapped(*args, **kwargs) + # This logic can be used for uvloop, but should + # work for any valid custom loop factory. + + # A custom loop_factory will be used to create + # a new event loop instance. It will then run + # the main() coroutine on this event loop. Once + # this coroutine is complete, the event loop will + # be stopped and closed. + + # The new loop that is created and set as the + # running loop of the duration of the run() call. + # When the coroutine starts, it runs in the context + # that was active when run() was called. Any tasks + # created within this coroutine on this new event + # loop will inherit that context. + + # Note: The loop created by loop_factory is never + # set as the global current loop for the thread, + # even while it is running. + loop = instance._loop + _instrument_event_loop(loop) + + return result + + def instrument_asyncio_base_events(module): - wrap_out_function(module, "BaseEventLoop.create_task", propagate_task_context) + wrap_out_function(module, "BaseEventLoop.create_task", wrap_create_task) def instrument_asyncio_events(module): if hasattr(module, "_BaseDefaultEventLoopPolicy"): # Python >= 3.14 - wrap_function_wrapper(module, "_BaseDefaultEventLoopPolicy.set_event_loop", wrap_create_task) - else: # Python <= 3.13 - wrap_function_wrapper(module, "BaseDefaultEventLoopPolicy.set_event_loop", wrap_create_task) + wrap_function_wrapper(module, "_BaseDefaultEventLoopPolicy.set_event_loop", wrap_set_event_loop) + elif hasattr(module, "BaseDefaultEventLoopPolicy"): # Python <= 3.13 + wrap_function_wrapper(module, "BaseDefaultEventLoopPolicy.set_event_loop", wrap_set_event_loop) + + +# For Python >= 3.11 +def instrument_asyncio_runners(module): + if hasattr(module, "Runner") and hasattr(module.Runner, "_lazy_init"): + wrap_function_wrapper(module, "Runner._lazy_init", wrap__lazy_init) diff --git a/tests/adapter_uvicorn/test_uvicorn.py b/tests/adapter_uvicorn/test_uvicorn.py index 0084be3e46..d5db2d6ca6 100644 --- a/tests/adapter_uvicorn/test_uvicorn.py +++ b/tests/adapter_uvicorn/test_uvicorn.py @@ -56,8 +56,8 @@ def app(request): return request.param -@pytest.fixture -def port(app): +@pytest.fixture(params=["asyncio", "uvloop", "none"], ids=["asyncio", "uvloop", "none"]) +def port(app, request): port = get_open_port() loops = [] @@ -72,7 +72,7 @@ def on_tick_sync(): async def on_tick(): on_tick_sync() - config = Config(app, host="127.0.0.1", port=port, loop="asyncio") + config = Config(app, host="127.0.0.1", port=port, loop=request.param) config.callback_notify = on_tick config.log_config = {"version": 1} config.disable_lifespan = True diff --git a/tests/coroutines_asyncio/test_context_propagation.py b/tests/coroutines_asyncio/test_context_propagation.py index b338b6ec3e..eb5c358745 100644 --- a/tests/coroutines_asyncio/test_context_propagation.py +++ b/tests/coroutines_asyncio/test_context_propagation.py @@ -36,16 +36,31 @@ import uvloop loop_policies = (pytest.param(None, id="asyncio"), pytest.param(uvloop.EventLoopPolicy(), id="uvloop")) + uvloop_factory = (pytest.param(uvloop.new_event_loop, id="uvloop"), pytest.param(None, id="None")) except ImportError: loop_policies = (pytest.param(None, id="asyncio"),) + uvloop_factory = (pytest.param(None, id="None"),) + + +def loop_factories(): + import asyncio + + if sys.platform == "win32": + return (pytest.param(asyncio.ProactorEventLoop, id="asyncio.ProactorEventLoop"), *uvloop_factory) + else: + return (pytest.param(asyncio.SelectorEventLoop, id="asyncio.SelectorEventLoop"), *uvloop_factory) @pytest.fixture(autouse=True) def reset_event_loop(): - from asyncio import set_event_loop, set_event_loop_policy + try: + from asyncio import set_event_loop, set_event_loop_policy + + # Remove the loop policy to avoid side effects + set_event_loop_policy(None) + except ImportError: + from asyncio import set_event_loop - # Remove the loop policy to avoid side effects - set_event_loop_policy(None) set_event_loop(None) @@ -102,6 +117,7 @@ async def _test(asyncio, schedule, nr_enabled=True): return trace +@pytest.mark.skipif(sys.version_info >= (3, 16), reason="loop_policy is not available") @pytest.mark.parametrize("loop_policy", loop_policies) @pytest.mark.parametrize("schedule", ("create_task", "ensure_future")) @validate_transaction_metrics( @@ -166,10 +182,12 @@ def handle_exception(loop, context): memcache_trace("cmd"), ], ) -def test_two_transactions(event_loop, trace): +def test_two_transactions_with_global_event_loop(event_loop, trace): """ Instantiate a coroutine in one transaction and await it in another. This should not cause any errors. + This uses the global event loop policy, which has been deprecated + since Python 3.11 and is scheduled for removal in Python 3.16. """ import asyncio @@ -211,6 +229,99 @@ async def await_task(): event_loop.run_until_complete(asyncio.gather(afut, bfut)) +@pytest.mark.skipif(sys.version_info < (3, 11), reason="asyncio.Runner is not available") +@validate_transaction_metrics("await_task", background_task=True) +@validate_transaction_metrics("create_coro", background_task=True, index=-2) +@pytest.mark.parametrize("loop_factory", loop_factories()) +@pytest.mark.parametrize( + "trace", + [ + function_trace(name="simple_gen"), + external_trace(library="lib", url="http://foo.com"), + database_trace("select * from foo"), + datastore_trace("lib", "foo", "bar"), + message_trace("lib", "op", "typ", "name"), + memcache_trace("cmd"), + ], +) +def test_two_transactions_with_loop_factory(trace, loop_factory): + """ + Instantiate a coroutine in one transaction and await it in + another. This should not cause any errors. + Starting in Python 3.11, the asyncio.Runner class was added + as well as the loop_factory parameter. The loop_factory + parameter provides a replacement for loop policies (which + are scheduled for removal in Python 3.16). + """ + import asyncio + + @trace + async def task(): + pass + + @background_task(name="create_coro") + async def create_coro(): + return asyncio.create_task(task()) + + @background_task(name="await_task") + async def await_task(task_to_await): + return await task_to_await + + async def _main(): + _task = await create_coro() + return await await_task(_task) + + with asyncio.Runner(loop_factory=loop_factory) as runner: + runner.run(_main()) + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="loop_factory/asyncio.Runner is not available") +@pytest.mark.parametrize("loop_factory", loop_factories()) +@validate_transaction_metrics( + "test_context_propagation:test_context_propagation_with_loop_factory", + background_task=True, + scoped_metrics=(("Function/waiter2", 2), ("Function/waiter3", 2)), +) +@background_task() +def test_context_propagation_with_loop_factory(loop_factory): + import asyncio + + exceptions = [] + + def handle_exception(loop, context): + exceptions.append(context) + + # Call default handler for standard logging + loop.default_exception_handler(context) + + async def subtask(): + with FunctionTrace(name="waiter2", terminal=True): + pass + + await child() + + async def _task(trace): + assert current_trace() == trace + + await subtask() + + trace = current_trace() + + with asyncio.Runner(loop_factory=loop_factory) as runner: + assert trace == current_trace() + runner._loop.set_exception_handler(handle_exception) + runner.run(_task(trace)) + runner.run(_task(trace)) + + # The agent should have removed all traces from the cache since + # run_until_complete has terminated (all callbacks scheduled inside the + # task have run) + assert len(trace_cache()) == 1 # Sentinel is all that remains + + # # Assert that no exceptions have occurred + assert not exceptions, exceptions + + # Sentinel left in cache transaction exited async def sentinel_in_cache_txn_exited(asyncio, bg): event = asyncio.Event() diff --git a/tox.ini b/tox.ini index e27ce2ef83..98cea6ee29 100644 --- a/tox.ini +++ b/tox.ini @@ -116,8 +116,8 @@ envlist = python-adapter_hypercorn-{py310,py311,py312,py313,py314}-hypercornlatest, python-adapter_hypercorn-{py38,py39}-hypercorn{0010,0011,0012,0013}, python-adapter_mcp-{py310,py311,py312,py313,py314}, - python-adapter_uvicorn-{py38,py39,py310,py311,py312,py313,py314}-uvicornlatest, - python-adapter_uvicorn-py38-uvicorn014, + python-adapter_uvicorn-{py39,py310,py311,py312,py313,py314}-uvicornlatest, + python-adapter_uvicorn-py38-uvicorn020, python-adapter_waitress-{py38,py39,py310,py311,py312,py313,py314}-waitresslatest, python-application_celery-{py38,py39,py310,py311,py312,py313,py314,pypy311}-celerylatest, python-application_celery-py311-celery{0504,0503,0502}, @@ -239,9 +239,11 @@ deps = adapter_hypercorn-hypercorn0010: hypercorn[h3]<0.11 adapter_hypercorn: niquests adapter_mcp: fastmcp - adapter_uvicorn-uvicorn014: uvicorn<0.15 + adapter_uvicorn-uvicorn020: uvicorn<0.21 + adapter_uvicorn-uvicorn020: uvloop<0.20 adapter_uvicorn-uvicornlatest: uvicorn adapter_uvicorn: typing-extensions + adapter_uvicorn: uvloop adapter_waitress: WSGIProxy2 adapter_waitress-waitresslatest: waitress agent_features: beautifulsoup4 From f1815857622a7f4e47c5e3051b24ae755349d7fd Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:45:06 -0800 Subject: [PATCH 21/21] Fix issue in ASGI header consumption (#1578) * Correct code for Sanic instrumentation * Correct handling of headers in ASGIWebTransaction * Correct handling of headers in ASGIBrowserMiddleware * Add regression test for ASGI headers issues --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- newrelic/api/asgi_application.py | 20 ++++++++++++++-- newrelic/hooks/framework_sanic.py | 2 +- tests/agent_features/test_asgi_transaction.py | 24 +++++++++++++++++++ tests/testing_support/asgi_testing.py | 2 +- .../sample_asgi_applications.py | 17 +++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/newrelic/api/asgi_application.py b/newrelic/api/asgi_application.py index 669d3e6db5..6b9a31130e 100644 --- a/newrelic/api/asgi_application.py +++ b/newrelic/api/asgi_application.py @@ -132,10 +132,20 @@ async def send_inject_browser_agent(self, message): message_type = message["type"] if message_type == "http.response.start" and not self.initial_message: - headers = list(message.get("headers", ())) + # message["headers"] may be a generator, and consuming it via process_response will leave the original + # application with no headers. Fix this by preserving them in a list before consuming them. + if "headers" in message: + message["headers"] = headers = list(message["headers"]) + else: + headers = [] + + # Check if we should insert the HTML snippet based on the headers. + # Currently if there are no headers this will always be False, but call the function + # anyway in case this logic changes in the future. if not self.should_insert_html(headers): await self.abort() return + message["headers"] = headers self.initial_message = message elif message_type == "http.response.body" and self.initial_message: @@ -232,7 +242,13 @@ async def send(self, event): finally: self.__exit__(*sys.exc_info()) elif event["type"] == "http.response.start": - self.process_response(event["status"], event.get("headers", ())) + # event["headers"] may be a generator, and consuming it via process_response will leave the original + # ASGI application with no headers. Fix this by preserving them in a list before consuming them. + if "headers" in event: + event["headers"] = headers = list(event["headers"]) + else: + headers = [] + self.process_response(event["status"], headers) return await self._send(event) diff --git a/newrelic/hooks/framework_sanic.py b/newrelic/hooks/framework_sanic.py index 14077eb6d9..74d8ab678e 100644 --- a/newrelic/hooks/framework_sanic.py +++ b/newrelic/hooks/framework_sanic.py @@ -183,7 +183,7 @@ async def _nr_sanic_response_send(wrapped, instance, args, kwargs): transaction = current_transaction() result = wrapped(*args, **kwargs) if isawaitable(result): - await result + result = await result if transaction is None: return result diff --git a/tests/agent_features/test_asgi_transaction.py b/tests/agent_features/test_asgi_transaction.py index e70ec95901..ac774689bd 100644 --- a/tests/agent_features/test_asgi_transaction.py +++ b/tests/agent_features/test_asgi_transaction.py @@ -19,6 +19,7 @@ from testing_support.fixtures import override_application_settings from testing_support.sample_asgi_applications import ( AppWithDescriptor, + asgi_application_generator_headers, simple_app_v2, simple_app_v2_init_exc, simple_app_v2_raw, @@ -37,6 +38,7 @@ simple_app_v3_wrapped = AsgiTest(simple_app_v3) simple_app_v2_wrapped = AsgiTest(simple_app_v2) simple_app_v2_init_exc = AsgiTest(simple_app_v2_init_exc) +asgi_application_generator_headers = AsgiTest(asgi_application_generator_headers) # Test naming scheme logic and ASGIApplicationWrapper for a single callable @@ -85,6 +87,28 @@ def test_double_callable_raw(): assert response.body == b"" +# Ensure headers object is preserved +@pytest.mark.parametrize("browser_monitoring", [True, False]) +@validate_transaction_metrics(name="", group="Uri") +def test_generator_headers(browser_monitoring): + """ + Both ASGIApplicationWrapper and ASGIBrowserMiddleware can cause headers to be lost if generators are + not handled properly. + + Ensure neither destroys headers by testing with and without the ASGIBrowserMiddleware, to make sure whichever + receives headers first properly preserves them in a list. + """ + + @override_application_settings({"browser_monitoring.enabled": browser_monitoring}) + def _test(): + response = asgi_application_generator_headers.make_request("GET", "/") + assert response.status == 200 + assert response.headers == {"x-my-header": "myvalue"} + assert response.body == b"" + + _test() + + # Test asgi_application decorator with parameters passed in on a single callable @pytest.mark.parametrize("name, group", ((None, "group"), ("name", "group"), ("", "group"))) def test_asgi_application_decorator_single_callable(name, group): diff --git a/tests/testing_support/asgi_testing.py b/tests/testing_support/asgi_testing.py index 821a20fe96..5c97be8860 100644 --- a/tests/testing_support/asgi_testing.py +++ b/tests/testing_support/asgi_testing.py @@ -106,7 +106,7 @@ def process_output(self): if self.response_state is ResponseState.NOT_STARTED: assert message["type"] == "http.response.start" response_status = message["status"] - response_headers = message.get("headers", response_headers) + response_headers = list(message.get("headers", response_headers)) self.response_state = ResponseState.BODY elif self.response_state is ResponseState.BODY: assert message["type"] == "http.response.body" diff --git a/tests/testing_support/sample_asgi_applications.py b/tests/testing_support/sample_asgi_applications.py index c1ef860763..e281a7cbf2 100644 --- a/tests/testing_support/sample_asgi_applications.py +++ b/tests/testing_support/sample_asgi_applications.py @@ -114,6 +114,23 @@ async def normal_asgi_application(scope, receive, send): await send({"type": "http.response.body", "body": output}) +@ASGIApplicationWrapper +async def asgi_application_generator_headers(scope, receive, send): + if scope["type"] == "lifespan": + return await handle_lifespan(scope, receive, send) + + if scope["type"] != "http": + raise ValueError("unsupported") + + def headers(): + yield (b"x-my-header", b"myvalue") + + await send({"type": "http.response.start", "status": 200, "headers": headers()}) + await send({"type": "http.response.body"}) + + assert current_transaction() is None + + async def handle_lifespan(scope, receive, send): """Handle lifespan protocol with no-ops to allow more compatibility.""" while True: