diff --git a/.coveragerc36 b/.coveragerc36 new file mode 100644 index 0000000000..8642882ab1 --- /dev/null +++ b/.coveragerc36 @@ -0,0 +1,14 @@ +# This is the coverage.py config for Python 3.6 +# The config for newer Python versions is in pyproject.toml. + +[run] +branch = true +omit = + /tmp/* + */tests/* + */.venv/* + + +[report] +exclude_lines = + if TYPE_CHECKING: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e6415b65..ed035b4ab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: 3.12 @@ -39,7 +39,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: 3.12 @@ -54,7 +54,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: 3.12 @@ -70,11 +70,14 @@ jobs: # This will also trigger "make dist" that creates the Python packages make aws-lambda-layer - name: Upload Python Packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: ${{ github.sha }} + name: artifact-build_lambda_layer path: | dist/* + if-no-files-found: 'error' + # since this artifact will be merged, compression is not necessary + compression-level: '0' docs: name: Build SDK API Doc @@ -82,7 +85,7 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: 3.12 @@ -91,7 +94,23 @@ jobs: make apidocs cd docs/_build && zip -r gh-pages ./ - - uses: actions/upload-artifact@v3.1.1 + - uses: actions/upload-artifact@v4 + with: + name: artifact-docs + path: | + docs/_build/gh-pages.zip + if-no-files-found: 'error' + # since this artifact will be merged, compression is not necessary + compression-level: '0' + + merge: + name: Create Release Artifact + runs-on: ubuntu-latest + needs: [build_lambda_layer, docs] + steps: + - uses: actions/upload-artifact/merge@v4 with: + # Craft expects release assets from github to be a single artifact named after the sha. name: ${{ github.sha }} - path: docs/_build/gh-pages.zip + pattern: artifact-* + delete-merged: true diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 86cba0e022..e362d1e620 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -17,13 +17,15 @@ on: - master - sentry-sdk-2.0 pull_request: - # The branches below must be a subset of the branches above - branches: - - master - - sentry-sdk-2.0 schedule: - cron: '18 18 * * 3' +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + permissions: contents: read @@ -46,7 +48,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4.1.7 + uses: actions/checkout@v4.2.2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/enforce-license-compliance.yml b/.github/workflows/enforce-license-compliance.yml index 01e02ccb8b..ef79ed112b 100644 --- a/.github/workflows/enforce-license-compliance.yml +++ b/.github/workflows/enforce-license-compliance.yml @@ -8,10 +8,11 @@ on: - release/* - sentry-sdk-2.0 pull_request: - branches: - - master - - main - - sentry-sdk-2.0 + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} jobs: enforce-license-compliance: diff --git a/.github/workflows/release-comment-issues.yml b/.github/workflows/release-comment-issues.yml new file mode 100644 index 0000000000..d31c61dced --- /dev/null +++ b/.github/workflows/release-comment-issues.yml @@ -0,0 +1,31 @@ +name: "Automation: Notify issues for release" +on: + release: + types: + - published + workflow_dispatch: + inputs: + version: + description: Which version to notify issues for + required: false + +# This workflow is triggered when a release is published +jobs: + release-comment-issues: + runs-on: ubuntu-20.04 + name: Notify issues + steps: + - name: Get version + id: get_version + run: echo "version=${{ github.event.inputs.version || github.event.release.tag_name }}" >> $GITHUB_OUTPUT + + - name: Comment on linked issues that are mentioned in release + if: | + steps.get_version.outputs.version != '' + && !contains(steps.get_version.outputs.version, 'a') + && !contains(steps.get_version.outputs.version, 'b') + && !contains(steps.get_version.outputs.version, 'rc') + uses: getsentry/release-comment-issues-gh-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + version: ${{ steps.get_version.outputs.version }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd560bb17a..2cd3dfb2ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,14 +18,20 @@ jobs: runs-on: ubuntu-latest name: "Release a new version" steps: - - uses: actions/checkout@v4.1.7 + - name: Get auth token + id: token + uses: actions/create-github-app-token@5d869da34e18e7287c1daad50e0b8ea0f506ce69 # v1.11.0 with: - token: ${{ secrets.GH_RELEASE_PAT }} + app-id: ${{ vars.SENTRY_RELEASE_BOT_CLIENT_ID }} + private-key: ${{ secrets.SENTRY_RELEASE_BOT_PRIVATE_KEY }} + - uses: actions/checkout@v4.2.2 + with: + token: ${{ steps.token.outputs.token }} fetch-depth: 0 - name: Prepare release uses: getsentry/action-prepare-release@v1 env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_PAT }} + GITHUB_TOKEN: ${{ steps.token.outputs.token }} with: version: ${{ github.event.inputs.version }} force: ${{ github.event.inputs.force }} diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 2039a00b35..8be64736c1 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -1,3 +1,5 @@ +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py name: Test AI on: push: @@ -25,14 +27,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.9","3.11","3.12"] + python-version: ["3.7","3.9","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -63,23 +65,33 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-huggingface_hub-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true test-ai-pinned: name: AI (pinned) timeout-minutes: 30 @@ -87,14 +99,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.9","3.11","3.12"] + python-version: ["3.8","3.9","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -125,25 +137,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-huggingface_hub" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All AI tests passed + name: All pinned AI tests passed needs: test-ai-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws.yml similarity index 78% rename from .github/workflows/test-integrations-aws-lambda.yml rename to .github/workflows/test-integrations-aws.yml index 119545c9f6..6eed3a3ab1 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws.yml @@ -1,4 +1,6 @@ -name: Test AWS Lambda +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test AWS on: push: branches: @@ -30,7 +32,7 @@ jobs: name: permissions check runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 with: persist-credentials: false - name: Check permissions on PR @@ -50,8 +52,8 @@ jobs: - name: Check permissions on repo branch if: github.event_name == 'push' run: true - test-aws_lambda-pinned: - name: AWS Lambda (pinned) + test-aws-pinned: + name: AWS (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: @@ -65,7 +67,7 @@ jobs: os: [ubuntu-20.04] needs: check-permissions steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} - uses: actions/setup-python@v5 @@ -82,31 +84,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-aws_lambda" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All AWS Lambda tests passed - needs: test-aws_lambda-pinned + name: All pinned AWS tests passed + needs: test-aws-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-aws_lambda-pinned.result, 'failure') || contains(needs.test-aws_lambda-pinned.result, 'skipped') + if: contains(needs.test-aws-pinned.result, 'failure') || contains(needs.test-aws-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud.yml similarity index 72% rename from .github/workflows/test-integrations-cloud-computing.yml rename to .github/workflows/test-integrations-cloud.yml index 531303bf52..677385e405 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -1,4 +1,6 @@ -name: Test Cloud Computing +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Cloud on: push: branches: @@ -18,21 +20,21 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-cloud_computing-latest: - name: Cloud Computing (latest) + test-cloud-latest: + name: Cloud (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.8","3.11","3.12"] + python-version: ["3.8","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -59,38 +61,48 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-gcp-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-cloud_computing-pinned: - name: Cloud Computing (pinned) + verbose: true + test-cloud-pinned: + name: Cloud (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.9","3.11","3.12"] + python-version: ["3.6","3.7","3.9","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -117,31 +129,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-gcp" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Cloud Computing tests passed - needs: test-cloud_computing-pinned + name: All pinned Cloud tests passed + needs: test-cloud-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-cloud_computing-pinned.result, 'failure') || contains(needs.test-cloud_computing-pinned.result, 'skipped') + if: contains(needs.test-cloud-pinned.result, 'failure') || contains(needs.test-cloud-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index a32f300512..9c476553f5 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -1,3 +1,5 @@ +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py name: Test Common on: push: @@ -32,7 +34,7 @@ jobs: # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -47,25 +49,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-common" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Common tests passed + name: All pinned Common tests passed needs: test-common-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-dbs.yml similarity index 78% rename from .github/workflows/test-integrations-databases.yml rename to .github/workflows/test-integrations-dbs.yml index c547e1a9da..cbaa2c32d2 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -1,4 +1,6 @@ -name: Test Databases +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test DBs on: push: branches: @@ -18,14 +20,14 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-databases-latest: - name: Databases (latest) + test-dbs-latest: + name: DBs (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.7","3.8","3.11","3.12"] + python-version: ["3.7","3.8","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 @@ -50,12 +52,12 @@ jobs: SENTRY_PYTHON_TEST_POSTGRES_USER: postgres SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - uses: getsentry/action-clickhouse-in-ci@v1 + - uses: getsentry/action-clickhouse-in-ci@v1.1 - name: Setup Test Env run: | pip install "coverage[toml]" tox @@ -86,25 +88,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-sqlalchemy-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-databases-pinned: - name: Databases (pinned) + verbose: true + test-dbs-pinned: + name: DBs (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: @@ -135,12 +147,12 @@ jobs: SENTRY_PYTHON_TEST_POSTGRES_USER: postgres SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} allow-prereleases: true - - uses: getsentry/action-clickhouse-in-ci@v1 + - uses: getsentry/action-clickhouse-in-ci@v1.1 - name: Setup Test Env run: | pip install "coverage[toml]" tox @@ -171,31 +183,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-sqlalchemy" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Databases tests passed - needs: test-databases-pinned + name: All pinned DBs tests passed + needs: test-dbs-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-databases-pinned.result, 'failure') || contains(needs.test-databases-pinned.result, 'skipped') + if: contains(needs.test-dbs-pinned.result, 'failure') || contains(needs.test-dbs-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index d5f78aaa89..d582717fff 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -1,3 +1,5 @@ +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py name: Test GraphQL on: push: @@ -25,14 +27,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.7","3.8","3.11","3.12"] + python-version: ["3.7","3.8","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -59,23 +61,33 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-strawberry-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true test-graphql-pinned: name: GraphQL (pinned) timeout-minutes: 30 @@ -90,7 +102,7 @@ jobs: # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -117,25 +129,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-strawberry" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All GraphQL tests passed + name: All pinned GraphQL tests passed needs: test-graphql-pinned # Always run this, even if a dependent job failed if: always() diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-misc.yml similarity index 63% rename from .github/workflows/test-integrations-miscellaneous.yml rename to .github/workflows/test-integrations-misc.yml index 71ee0a2f1c..00b1286362 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -1,4 +1,6 @@ -name: Test Miscellaneous +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Misc on: push: branches: @@ -18,21 +20,21 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-miscellaneous-latest: - name: Miscellaneous (latest) + test-misc-latest: + name: Misc (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.8","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -43,10 +45,18 @@ jobs: - name: Erase coverage run: | coverage erase + - name: Test launchdarkly latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-launchdarkly-latest" - name: Test loguru latest run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-loguru-latest" + - name: Test openfeature latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-openfeature-latest" - name: Test opentelemetry latest run: | set -x # print commands that are executed @@ -63,38 +73,52 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-trytond-latest" + - name: Test typer latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-typer-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-miscellaneous-pinned: - name: Miscellaneous (pinned) + verbose: true + test-misc-pinned: + name: Misc (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -105,10 +129,18 @@ jobs: - name: Erase coverage run: | coverage erase + - name: Test launchdarkly pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-launchdarkly" - name: Test loguru pinned run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-loguru" + - name: Test openfeature pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-openfeature" - name: Test opentelemetry pinned run: | set -x # print commands that are executed @@ -125,31 +157,45 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-trytond" + - name: Test typer pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-typer" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Miscellaneous tests passed - needs: test-miscellaneous-pinned + name: All pinned Misc tests passed + needs: test-misc-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-miscellaneous-pinned.result, 'failure') || contains(needs.test-miscellaneous-pinned.result, 'skipped') + if: contains(needs.test-misc-pinned.result, 'failure') || contains(needs.test-misc-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-network.yml similarity index 72% rename from .github/workflows/test-integrations-networking.yml rename to .github/workflows/test-integrations-network.yml index 295f6bcffc..8f6bd9fd61 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-network.yml @@ -1,4 +1,6 @@ -name: Test Networking +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Network on: push: branches: @@ -18,21 +20,21 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-networking-latest: - name: Networking (latest) + test-network-latest: + name: Network (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.8","3.9","3.11","3.12"] + python-version: ["3.8","3.9","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -59,38 +61,48 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-requests-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-networking-pinned: - name: Networking (pinned) + verbose: true + test-network-pinned: + name: Network (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -117,31 +129,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-requests" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Networking tests passed - needs: test-networking-pinned + name: All pinned Network tests passed + needs: test-network-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-networking-pinned.result, 'failure') || contains(needs.test-networking-pinned.result, 'skipped') + if: contains(needs.test-network-pinned.result, 'failure') || contains(needs.test-network-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-tasks.yml similarity index 68% rename from .github/workflows/test-integrations-data-processing.yml rename to .github/workflows/test-integrations-tasks.yml index 1585adb20e..74c868d9b9 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -1,4 +1,6 @@ -name: Test Data Processing +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Tasks on: push: branches: @@ -18,21 +20,21 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-data_processing-latest: - name: Data Processing (latest) + test-tasks-latest: + name: Tasks (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.10","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.10","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -57,10 +59,18 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-celery-latest" + - name: Test dramatiq latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-dramatiq-latest" - name: Test huey latest run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-huey-latest" + - name: Test ray latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-ray-latest" - name: Test rq latest run: | set -x # print commands that are executed @@ -69,25 +79,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-spark-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-data_processing-pinned: - name: Data Processing (pinned) + verbose: true + test-tasks-pinned: + name: Tasks (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: @@ -100,7 +120,7 @@ jobs: # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -125,10 +145,18 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-celery" + - name: Test dramatiq pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-dramatiq" - name: Test huey pinned run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-huey" + - name: Test ray pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-ray" - name: Test rq pinned run: | set -x # print commands that are executed @@ -137,31 +165,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-spark" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Data Processing tests passed - needs: test-data_processing-pinned + name: All pinned Tasks tests passed + needs: test-tasks-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-data_processing-pinned.result, 'failure') || contains(needs.test-data_processing-pinned.result, 'skipped') + if: contains(needs.test-tasks-pinned.result, 'failure') || contains(needs.test-tasks-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-1.yml similarity index 77% rename from .github/workflows/test-integrations-web-frameworks-1.yml rename to .github/workflows/test-integrations-web-1.yml index 835dd724b3..5be067a36b 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -1,4 +1,6 @@ -name: Test Web Frameworks 1 +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Web 1 on: push: branches: @@ -18,14 +20,14 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-web_frameworks_1-latest: - name: Web Frameworks 1 (latest) + test-web_1-latest: + name: Web 1 (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.8","3.10","3.11","3.12"] + python-version: ["3.8","3.10","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 @@ -50,7 +52,7 @@ jobs: SENTRY_PYTHON_TEST_POSTGRES_USER: postgres SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -77,25 +79,35 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-fastapi-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-web_frameworks_1-pinned: - name: Web Frameworks 1 (pinned) + verbose: true + test-web_1-pinned: + name: Web 1 (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: @@ -126,7 +138,7 @@ jobs: SENTRY_PYTHON_TEST_POSTGRES_USER: postgres SENTRY_PYTHON_TEST_POSTGRES_PASSWORD: sentry steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -153,31 +165,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-fastapi" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Web Frameworks 1 tests passed - needs: test-web_frameworks_1-pinned + name: All pinned Web 1 tests passed + needs: test-web_1-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-web_frameworks_1-pinned.result, 'failure') || contains(needs.test-web_frameworks_1-pinned.result, 'skipped') + if: contains(needs.test-web_1-pinned.result, 'failure') || contains(needs.test-web_1-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-2.yml similarity index 78% rename from .github/workflows/test-integrations-web-frameworks-2.yml rename to .github/workflows/test-integrations-web-2.yml index c56451b751..7ce0399a13 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -1,4 +1,6 @@ -name: Test Web Frameworks 2 +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py +name: Test Web 2 on: push: branches: @@ -18,21 +20,21 @@ env: CACHED_BUILD_PATHS: | ${{ github.workspace }}/dist-serverless jobs: - test-web_frameworks_2-latest: - name: Web Frameworks 2 (latest) + test-web_2-latest: + name: Web 2 (latest) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -83,38 +85,48 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh "py${{ matrix.python-version }}-tornado-latest" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml - test-web_frameworks_2-pinned: - name: Web Frameworks 2 (pinned) + verbose: true + test-web_2-pinned: + name: Web 2 (pinned) timeout-minutes: 30 runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.8","3.9","3.11","3.12"] + python-version: ["3.6","3.7","3.8","3.9","3.11","3.12","3.13"] # python3.6 reached EOL and is no longer being supported on # new versions of hosted runners on Github Actions # ubuntu-20.04 is the last version that supported python3.6 # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -165,31 +177,41 @@ jobs: run: | set -x # print commands that are executed ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-tornado" + - name: Generate coverage XML (Python 3.6) + if: ${{ !cancelled() && matrix.python-version == '3.6' }} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors - name: Generate coverage XML - if: ${{ !cancelled() }} + if: ${{ !cancelled() && matrix.python-version != '3.6' }} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} files: .junitxml + verbose: true check_required_tests: - name: All Web Frameworks 2 tests passed - needs: test-web_frameworks_2-pinned + name: All pinned Web 2 tests passed + needs: test-web_2-pinned # Always run this, even if a dependent job failed if: always() runs-on: ubuntu-20.04 steps: - name: Check for failures - if: contains(needs.test-web_frameworks_2-pinned.result, 'failure') || contains(needs.test-web_frameworks_2-pinned.result, 'skipped') + if: contains(needs.test-web_2-pinned.result, 'failure') || contains(needs.test-web_2-pinned.result, 'skipped') run: | echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1 diff --git a/.gitignore b/.gitignore index cfd8070197..8c7a5f2174 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ *.db *.pid .python-version -.coverage* +.coverage +.coverage-sentry* +coverage.xml .junitxml* .DS_Store .tox diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c741e1224..af4eb04fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,369 @@ # Changelog +## 2.19.2 + +### Various fixes & improvements + +- Deepcopy and ensure get_all function always terminates (#3861) by @cmanallen +- Cleanup chalice test environment (#3858) by @antonpirker + +## 2.19.1 + +### Various fixes & improvements + +- Fix errors when instrumenting Django cache (#3855) by @BYK +- Copy `scope.client` reference as well (#3857) by @sl0thentr0py +- Don't give up on Spotlight on 3 errors (#3856) by @BYK +- Add missing stack frames (#3673) by @antonpirker +- Fix wrong metadata type in async gRPC interceptor (#3205) by @fdellekart +- Rename launch darkly hook to match JS SDK (#3743) by @aliu39 +- Script for checking if our instrumented libs are Python 3.13 compatible (#3425) by @antonpirker +- Improve Ray tests (#3846) by @antonpirker +- Test with Celery `5.5.0rc3` (#3842) by @sentrivana +- Fix asyncio testing setup (#3832) by @sl0thentr0py +- Bump `codecov/codecov-action` from `5.0.2` to `5.0.7` (#3821) by @dependabot +- Fix CI (#3834) by @sentrivana +- Use new ClickHouse GH action (#3826) by @antonpirker + +## 2.19.0 + +### Various fixes & improvements + +- New: introduce `rust_tracing` integration. See https://docs.sentry.io/platforms/python/integrations/rust_tracing/ (#3717) by @matt-codecov +- Auto enable Litestar integration (#3540) by @provinzkraut +- Deprecate `sentry_sdk.init` context manager (#3729) by @szokeasaurusrex +- feat(spotlight): Send PII to Spotlight when no DSN is set (#3804) by @BYK +- feat(spotlight): Add info logs when Sentry is enabled (#3735) by @BYK +- feat(spotlight): Inject Spotlight button on Django (#3751) by @BYK +- feat(spotlight): Auto enable cache_spans for Spotlight on DEBUG (#3791) by @BYK +- fix(logging): Handle parameter `stack_info` for the `LoggingIntegration` (#3745) by @gmcrocetti +- fix(pure-eval): Make sentry-sdk[pure-eval] installable with pip==24.0 (#3757) by @sentrivana +- fix(rust_tracing): include_tracing_fields arg to control unvetted data in rust_tracing integration (#3780) by @matt-codecov +- fix(aws) Fix aws lambda tests (by reducing event size) (#3770) by @antonpirker +- fix(arq): fix integration with Worker settings as a dict (#3742) by @saber-solooki +- fix(httpx): Prevent Sentry baggage duplication (#3728) by @szokeasaurusrex +- fix(falcon): Don't exhaust request body stream (#3768) by @szokeasaurusrex +- fix(integrations): Check `retries_left` before capturing exception (#3803) by @malkovro +- fix(openai): Use name instead of description (#3807) by @sourceful-rob +- test(gcp): Only run GCP tests when they should (#3721) by @szokeasaurusrex +- chore: Shorten CI workflow names (#3805) by @sentrivana +- chore: Test with pyspark prerelease (#3760) by @sentrivana +- build(deps): bump codecov/codecov-action from 4.6.0 to 5.0.2 (#3792) by @dependabot +- build(deps): bump actions/checkout from 4.2.1 to 4.2.2 (#3691) by @dependabot + +## 2.18.0 + +### Various fixes & improvements + +- Add LaunchDarkly and OpenFeature integration (#3648) by @cmanallen +- Correct typo in a comment (#3726) by @szokeasaurusrex +- End `http.client` span on timeout (#3723) by @Zylphrex +- Check for `h2` existence in HTTP/2 transport (#3690) by @BYK +- Use `type()` instead when extracting frames (#3716) by @Zylphrex +- Prefer `python_multipart` import over `multipart` (#3710) by @musicinmybrain +- Update active thread for asgi (#3669) by @Zylphrex +- Only enable HTTP2 when DSN is HTTPS (#3678) by @BYK +- Prepare for upstream Strawberry extension removal (#3649) by @DoctorJohn +- Enhance README with improved clarity and developer-friendly examples (#3667) by @UTSAVS26 +- Run license compliance action on all PRs (#3699) by @szokeasaurusrex +- Run CodeQL action on all PRs (#3698) by @szokeasaurusrex +- Fix UTC assuming test (#3722) by @BYK +- Exclude fakeredis 2.26.0 on py3.6 and 3.7 (#3695) by @szokeasaurusrex +- Unpin `pytest` for `tornado-latest` tests (#3714) by @szokeasaurusrex +- Install `pytest-asyncio` for `redis` tests (Python 3.12-13) (#3706) by @szokeasaurusrex +- Clarify that only pinned tests are required (#3713) by @szokeasaurusrex +- Remove accidentally-committed print (#3712) by @szokeasaurusrex +- Disable broken RQ test in newly-released RQ 2.0 (#3708) by @szokeasaurusrex +- Unpin `pytest` for `celery` tests (#3701) by @szokeasaurusrex +- Unpin `pytest` on Python 3.8+ `gevent` tests (#3700) by @szokeasaurusrex +- Unpin `pytest` for Python 3.8+ `common` tests (#3697) by @szokeasaurusrex +- Remove `pytest` pin in `requirements-devenv.txt` (#3696) by @szokeasaurusrex +- Test with Falcon 4.0 (#3684) by @sentrivana + +## 2.17.0 + +### Various fixes & improvements + +- Add support for async calls in Anthropic and OpenAI integration (#3497) by @vetyy +- Allow custom transaction names in ASGI (#3664) by @sl0thentr0py +- Langchain: Handle case when parent span wasn't traced (#3656) by @rbasoalto +- Fix Anthropic integration when using tool calls (#3615) by @kwnath +- More defensive Django Spotlight middleware injection (#3665) by @BYK +- Remove `ensure_integration_enabled_async` (#3632) by @sentrivana +- Test with newer Falcon version (#3644, #3653, #3662) by @sentrivana +- Fix mypy (#3657) by @sentrivana +- Fix flaky transport test (#3666) by @sentrivana +- Remove pin on `sphinx` (#3650) by @sentrivana +- Bump `actions/checkout` from `4.2.0` to `4.2.1` (#3651) by @dependabot + +## 2.16.0 + +### Integrations + +- Bottle: Add `failed_request_status_codes` (#3618) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + BottleIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- Bottle: Delete never-reached code (#3605) by @szokeasaurusrex +- Redis: Remove flaky test (#3626) by @sentrivana +- Django: Improve getting `psycopg3` connection info (#3580) by @nijel +- Django: Add `SpotlightMiddleware` when Spotlight is enabled (#3600) by @BYK +- Django: Open relevant error when `SpotlightMiddleware` is on (#3614) by @BYK +- Django: Support `http_methods_to_capture` in ASGI Django (#3607) by @sentrivana + + ASGI Django now also supports the `http_methods_to_capture` integration option. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it: + + ```python + sentry_sdk.init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +### Miscellaneous + +- Add 3.13 to setup.py (#3574) by @sentrivana +- Add 3.13 to basepython (#3589) by @sentrivana +- Fix type of `sample_rate` in DSC (and add explanatory tests) (#3603) by @antonpirker +- Add `httpcore` based `HTTP2Transport` (#3588) by @BYK +- Add opportunistic Brotli compression (#3612) by @BYK +- Add `__notes__` support (#3620) by @szokeasaurusrex +- Remove useless makefile targets (#3604) by @antonpirker +- Simplify tox version spec (#3609) by @sentrivana +- Consolidate contributing docs (#3606) by @antonpirker +- Bump `codecov/codecov-action` from `4.5.0` to `4.6.0` (#3617) by @dependabot + +## 2.15.0 + +### Integrations + +- Configure HTTP methods to capture in ASGI/WSGI middleware and frameworks (#3531) by @antonpirker + + We've added a new option to the Django, Flask, Starlette and FastAPI integrations called `http_methods_to_capture`. This is a configurable tuple of HTTP method verbs that should create a transaction in Sentry. The default is `("CONNECT", "DELETE", "GET", "PATCH", "POST", "PUT", "TRACE",)`. `OPTIONS` and `HEAD` are not included by default. + + Here's how to use it (substitute Flask for your framework integration): + + ```python + sentry_sdk.init( + integrations=[ + FlaskIntegration( + http_methods_to_capture=("GET", "POST"), + ), + ], + ) + ``` + +- Django: Allow ASGI to use `drf_request` in `DjangoRequestExtractor` (#3572) by @PakawiNz +- Django: Don't let `RawPostDataException` bubble up (#3553) by @sentrivana +- Django: Add `sync_capable` to `SentryWrappingMiddleware` (#3510) by @szokeasaurusrex +- AIOHTTP: Add `failed_request_status_codes` (#3551) by @szokeasaurusrex + + You can now define a set of integers that will determine which status codes + should be reported to Sentry. + + ```python + sentry_sdk.init( + integrations=[ + AioHttpIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ) + ] + ) + ``` + + Examples of valid `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- AIOHTTP: Delete test which depends on AIOHTTP behavior (#3568) by @szokeasaurusrex +- AIOHTTP: Handle invalid responses (#3554) by @szokeasaurusrex +- FastAPI/Starlette: Support new `failed_request_status_codes` (#3563) by @szokeasaurusrex + + The format of `failed_request_status_codes` has changed from a list + of integers and containers to a set: + + ```python + sentry_sdk.init( + integrations=StarletteIntegration( + failed_request_status_codes={403, *range(500, 600)}, + ), + ) + ``` + + The old way of defining `failed_request_status_codes` will continue to work + for the time being. Examples of valid new-style `failed_request_status_codes`: + + - `{500}` will only send events on HTTP 500. + - `{400, *range(500, 600)}` will send events on HTTP 400 as well as the 5xx range. + - `{500, 503}` will send events on HTTP 500 and 503. + - `set()` (the empty set) will not send events for any HTTP status code. + + The default is `{*range(500, 600)}`, meaning that all 5xx status codes are reported to Sentry. + +- FastAPI/Starlette: Fix `failed_request_status_codes=[]` (#3561) by @szokeasaurusrex +- FastAPI/Starlette: Remove invalid `failed_request_status_code` tests (#3560) by @szokeasaurusrex +- FastAPI/Starlette: Refactor shared test parametrization (#3562) by @szokeasaurusrex + +### Miscellaneous + +- Deprecate `sentry_sdk.metrics` (#3512) by @szokeasaurusrex +- Add `name` parameter to `start_span()` and deprecate `description` parameter (#3524 & #3525) by @antonpirker +- Fix `add_query_source` with modules outside of project root (#3313) by @rominf +- Test more integrations on 3.13 (#3578) by @sentrivana +- Fix trailing whitespace (#3579) by @sentrivana +- Improve `get_integration` typing (#3550) by @szokeasaurusrex +- Make import-related tests stable (#3548) by @BYK +- Fix breadcrumb sorting (#3511) by @sentrivana +- Fix breadcrumb timestamp casting and its tests (#3546) by @BYK +- Don't use deprecated `logger.warn` (#3552) by @sentrivana +- Fix Cohere API change (#3549) by @BYK +- Fix deprecation message (#3536) by @antonpirker +- Remove experimental `explain_plan` feature. (#3534) by @antonpirker +- X-fail one of the Lambda tests (#3592) by @antonpirker +- Update Codecov config (#3507) by @antonpirker +- Update `actions/upload-artifact` to `v4` with merge (#3545) by @joshuarli +- Bump `actions/checkout` from `4.1.7` to `4.2.0` (#3585) by @dependabot + +## 2.14.0 + +### Various fixes & improvements + +- New `SysExitIntegration` (#3401) by @szokeasaurusrex + + For more information, see the documentation for the [SysExitIntegration](https://docs.sentry.io/platforms/python/integrations/sys_exit). + +- Add `SENTRY_SPOTLIGHT` env variable support (#3443) by @BYK +- Support Strawberry `0.239.2` (#3491) by @szokeasaurusrex +- Add separate `pii_denylist` to `EventScrubber` and run it always (#3463) by @sl0thentr0py +- Celery: Add wrapper for `Celery().send_task` to support behavior as `Task.apply_async` (#2377) by @divaltor +- Django: SentryWrappingMiddleware.__init__ fails if super() is object (#2466) by @cameron-simpson +- Fix data_category for sessions envelope items (#3473) by @sl0thentr0py +- Fix non-UTC timestamps (#3461) by @szokeasaurusrex +- Remove obsolete object as superclass (#3480) by @sentrivana +- Replace custom `TYPE_CHECKING` with stdlib `typing.TYPE_CHECKING` (#3447) by @dev-satoshi +- Refactor `tracing_utils.py` (#3452) by @rominf +- Explicitly export symbol in subpackages instead of ignoring (#3400) by @hartungstenio +- Better test coverage reports (#3498) by @antonpirker +- Fixed config for old coverage versions (#3504) by @antonpirker +- Fix AWS Lambda tests (#3495) by @antonpirker +- Remove broken Bottle tests (#3505) by @sentrivana + +## 2.13.0 + +### Various fixes & improvements + +- **New integration:** [Ray](https://docs.sentry.io/platforms/python/integrations/ray/) (#2400) (#2444) by @glowskir + + Usage: (add the RayIntegration to your `sentry_sdk.init()` call and make sure it is called in the worker processes) + ```python + import ray + + import sentry_sdk + from sentry_sdk.integrations.ray import RayIntegration + + def init_sentry(): + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[RayIntegration()], + ) + + init_sentry() + + ray.init( + runtime_env=dict(worker_process_setup_hook=init_sentry), + ) + ``` + For more information, see the documentation for the [Ray integration](https://docs.sentry.io/platforms/python/integrations/ray/). + +- **New integration:** [Litestar](https://docs.sentry.io/platforms/python/integrations/litestar/) (#2413) (#3358) by @KellyWalker + + Usage: (add the LitestarIntegration to your `sentry_sdk.init()`) + ```python + from litestar import Litestar, get + + import sentry_sdk + from sentry_sdk.integrations.litestar import LitestarIntegration + + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[LitestarIntegration()], + ) + + @get("/") + async def index() -> str: + return "Hello, world!" + + app = Litestar(...) + ``` + For more information, see the documentation for the [Litestar integration](https://docs.sentry.io/platforms/python/integrations/litestar/). + +- **New integration:** [Dramatiq](https://docs.sentry.io/platforms/python/integrations/dramatiq/) from @jacobsvante (#3397) by @antonpirker + Usage: (add the DramatiqIntegration to your `sentry_sdk.init()`) + ```python + import dramatiq + + import sentry_sdk + from sentry_sdk.integrations.dramatiq import DramatiqIntegration + + sentry_sdk.init( + dsn="...", + traces_sample_rate=1.0, + integrations=[DramatiqIntegration()], + ) + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send(12, 0) + ``` + + For more information, see the documentation for the [Dramatiq integration](https://docs.sentry.io/platforms/python/integrations/dramatiq/). + +- **New config option:** Expose `custom_repr` function that precedes `safe_repr` invocation in serializer (#3438) by @sl0thentr0py + + See: https://docs.sentry.io/platforms/python/configuration/options/#custom-repr + +- Profiling: Add client SDK info to profile chunk (#3386) by @Zylphrex +- Serialize vars early to avoid living references (#3409) by @sl0thentr0py +- Deprecate hub-based `sessions.py` logic (#3419) by @szokeasaurusrex +- Deprecate `is_auto_session_tracking_enabled` (#3428) by @szokeasaurusrex +- Add note to generated yaml files (#3423) by @sentrivana +- Slim down PR template (#3382) by @sentrivana +- Use new banner in readme (#3390) by @sentrivana + ## 2.12.0 ### Various fixes & improvements diff --git a/CONTRIBUTING-aws-lambda.md b/CONTRIBUTING-aws-lambda.md deleted file mode 100644 index 7a6a158b45..0000000000 --- a/CONTRIBUTING-aws-lambda.md +++ /dev/null @@ -1,21 +0,0 @@ -# Contributing to Sentry AWS Lambda Layer - -All the general terms of the [CONTRIBUTING.md](CONTRIBUTING.md) apply. - -## Development environment - -You need to have a AWS account and AWS CLI installed and setup. - -We put together two helper functions that can help you with development: - -- `./scripts/aws-deploy-local-layer.sh` - - This script [scripts/aws-deploy-local-layer.sh](scripts/aws-deploy-local-layer.sh) will take the code you have checked out locally, create a Lambda layer out of it and deploy it to the `eu-central-1` region of your configured AWS account using `aws` CLI. - - The Lambda layer will have the name `SentryPythonServerlessSDK-local-dev` - -- `./scripts/aws-attach-layer-to-lambda-function.sh` - - You can use this script [scripts/aws-attach-layer-to-lambda-function.sh](scripts/aws-attach-layer-to-lambda-function.sh) to attach the Lambda layer you just deployed (using the first script) onto one of your existing Lambda functions. You will have to give the name of the Lambda function to attach onto as an argument. (See the script for details.) - -With this two helper scripts it should be easy to rapidly iterate your development on the Lambda layer. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51765e7ef6..2f4839f8d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,3 +172,24 @@ sentry-sdk==2.4.0 ``` A major release `N` implies the previous release `N-1` will no longer receive updates. We generally do not backport bugfixes to older versions unless they are security relevant. However, feel free to ask for backports of specific commits on the bugtracker. + + +## Contributing to Sentry AWS Lambda Layer + +### Development environment + +You need to have an AWS account and AWS CLI installed and setup. + +We put together two helper functions that can help you with development: + +- `./scripts/aws-deploy-local-layer.sh` + + This script [scripts/aws-deploy-local-layer.sh](scripts/aws-deploy-local-layer.sh) will take the code you have checked out locally, create a Lambda layer out of it and deploy it to the `eu-central-1` region of your configured AWS account using `aws` CLI. + + The Lambda layer will have the name `SentryPythonServerlessSDK-local-dev` + +- `./scripts/aws-attach-layer-to-lambda-function.sh` + + You can use this script [scripts/aws-attach-layer-to-lambda-function.sh](scripts/aws-attach-layer-to-lambda-function.sh) to attach the Lambda layer you just deployed (using the first script) onto one of your existing Lambda functions. You will have to give the name of the Lambda function to attach onto as an argument. (See the script for details.) + +With these two helper scripts it should be easy to rapidly iterate your development on the Lambda layer. diff --git a/Makefile b/Makefile index f0affeca11..fb5900e5ea 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,11 @@ VENV_PATH = .venv help: @echo "Thanks for your interest in the Sentry Python SDK!" @echo - @echo "make lint: Run linters" - @echo "make test: Run basic tests (not testing most integrations)" - @echo "make test-all: Run ALL tests (slow, closest to CI)" - @echo "make format: Run code formatters (destructive)" + @echo "make apidocs: Build the API documentation" @echo "make aws-lambda-layer: Build AWS Lambda layer directory for serverless integration" @echo @echo "Also make sure to read ./CONTRIBUTING.md" + @echo @false .venv: @@ -24,30 +22,6 @@ dist: .venv $(VENV_PATH)/bin/python setup.py sdist bdist_wheel .PHONY: dist -format: .venv - $(VENV_PATH)/bin/tox -e linters --notest - .tox/linters/bin/black . -.PHONY: format - -test: .venv - @$(VENV_PATH)/bin/tox -e py3.12 -.PHONY: test - -test-all: .venv - @TOXPATH=$(VENV_PATH)/bin/tox sh ./scripts/runtox.sh -.PHONY: test-all - -check: lint test -.PHONY: check - -lint: .venv - @set -e && $(VENV_PATH)/bin/tox -e linters || ( \ - echo "================================"; \ - echo "Bad formatting? Run: make format"; \ - echo "================================"; \ - false) -.PHONY: lint - apidocs: .venv @$(VENV_PATH)/bin/pip install --editable . @$(VENV_PATH)/bin/pip install -U -r ./requirements-docs.txt @@ -55,11 +29,6 @@ apidocs: .venv @$(VENV_PATH)/bin/sphinx-build -vv -W -b html docs/ docs/_build .PHONY: apidocs -apidocs-hotfix: apidocs - @$(VENV_PATH)/bin/pip install ghp-import - @$(VENV_PATH)/bin/ghp-import -pf docs/_build -.PHONY: apidocs-hotfix - aws-lambda-layer: dist $(VENV_PATH)/bin/pip install -r requirements-aws-lambda-layer.txt $(VENV_PATH)/bin/python -m scripts.build_aws_lambda_layer diff --git a/README.md b/README.md index 6dba3f06ef..29501064f3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ Sentry for Python + _Bad software is everywhere, and we're tired of it. Sentry is on a mission to help developers write better software faster, so we can get back to enjoying technology. If you want to join us, [**check out our open positions**](https://sentry.io/careers/)_. # Official Sentry SDK for Python @@ -10,23 +11,27 @@ _Bad software is everywhere, and we're tired of it. Sentry is on a mission to he [![PyPi page link -- version](https://img.shields.io/pypi/v/sentry-sdk.svg)](https://pypi.python.org/pypi/sentry-sdk) [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/cWnMQeA) -This is the official Python SDK for [Sentry](http://sentry.io/) +Welcome to the official Python SDK for **[Sentry](http://sentry.io/)**! ## Getting Started -### Install +### Installation + +Getting Sentry into your project is straightforward. Just run this command in your terminal: ```bash pip install --upgrade sentry-sdk ``` -### Configuration +### Basic Configuration + +Here’s a quick configuration example to get Sentry up and running: ```python import sentry_sdk sentry_sdk.init( - "https://12927b5f211046b575ee51fd8b1ac34f@o1.ingest.sentry.io/1", + "https://12927b5f211046b575ee51fd8b1ac34f@o1.ingest.sentry.io/1", # Your DSN here # Set traces_sample_rate to 1.0 to capture 100% # of transactions for performance monitoring. @@ -34,78 +39,78 @@ sentry_sdk.init( ) ``` -### Usage +With this configuration, Sentry will monitor for exceptions and performance issues. + +### Quick Usage Example + +To generate some events that will show up in Sentry, you can log messages or capture errors: ```python from sentry_sdk import capture_message -capture_message("Hello World") # Will create an event in Sentry. +capture_message("Hello Sentry!") # You'll see this in your Sentry dashboard. -raise ValueError() # Will also create an event in Sentry. +raise ValueError("Oops, something went wrong!") # This will create an error event in Sentry. ``` -- To learn more about how to use the SDK [refer to our docs](https://docs.sentry.io/platforms/python/). -- Are you coming from `raven-python`? [Use this migration guide](https://docs.sentry.io/platforms/python/migration/). -- To learn about internals use the [API Reference](https://getsentry.github.io/sentry-python/). +#### Explore the Docs -## Integrations +For more details on advanced usage, integrations, and customization, check out the full documentation: -(If you want to create a new integration, have a look at the [Adding a new integration checklist](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md#adding-a-new-integration).) +- [Official SDK Docs](https://docs.sentry.io/platforms/python/) +- [API Reference](https://getsentry.github.io/sentry-python/) -See [the documentation](https://docs.sentry.io/platforms/python/integrations/) for an up-to-date list of libraries and frameworks we support. Here are some examples: +## Integrations + +Sentry integrates with many popular Python libraries and frameworks, including: - [Django](https://docs.sentry.io/platforms/python/integrations/django/) - [Flask](https://docs.sentry.io/platforms/python/integrations/flask/) - [FastAPI](https://docs.sentry.io/platforms/python/integrations/fastapi/) -- [AIOHTTP](https://docs.sentry.io/platforms/python/integrations/aiohttp/) -- [SQLAlchemy](https://docs.sentry.io/platforms/python/integrations/sqlalchemy/) -- [asyncpg](https://docs.sentry.io/platforms/python/integrations/asyncpg/) -- [Redis](https://docs.sentry.io/platforms/python/integrations/redis/) - [Celery](https://docs.sentry.io/platforms/python/integrations/celery/) -- [Apache Airflow](https://docs.sentry.io/platforms/python/integrations/airflow/) -- [Apache Spark](https://docs.sentry.io/platforms/python/integrations/pyspark/) -- [asyncio](https://docs.sentry.io/platforms/python/integrations/asyncio/) -- [Graphene](https://docs.sentry.io/platforms/python/integrations/graphene/) -- [Logging](https://docs.sentry.io/platforms/python/integrations/logging/) -- [Loguru](https://docs.sentry.io/platforms/python/integrations/loguru/) -- [HTTPX](https://docs.sentry.io/platforms/python/integrations/httpx/) - [AWS Lambda](https://docs.sentry.io/platforms/python/integrations/aws-lambda/) -- [Google Cloud Functions](https://docs.sentry.io/platforms/python/integrations/gcp-functions/) +Want more? [Check out the full list of integrations](https://docs.sentry.io/platforms/python/integrations/). + +### Rolling Your Own Integration? -## Migrating +If you want to create a new integration or improve an existing one, we’d welcome your contributions! Please read our [contributing guide](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md) before starting. -### Migrating From `1.x` to `2.x` +## Migrating Between Versions? -If you're on SDK version 1.x, we highly recommend updating to the 2.x major. To make the process easier we've prepared a [migration guide](https://docs.sentry.io/platforms/python/migration/1.x-to-2.x) with the most common changes as well as a [detailed changelog](MIGRATION_GUIDE.md). +### From `1.x` to `2.x` -### Migrating From `raven-python` +If you're using the older `1.x` version of the SDK, now's the time to upgrade to `2.x`. It includes significant upgrades and new features. Check our [migration guide](https://docs.sentry.io/platforms/python/migration/1.x-to-2.x) for assistance. -The old `raven-python` client has entered maintenance mode and was moved [here](https://github.com/getsentry/raven-python). +### From `raven-python` -If you're using `raven-python`, we recommend you to migrate to this new SDK. You can find the benefits of migrating and how to do it in our [migration guide](https://docs.sentry.io/platforms/python/migration/raven-to-sentry-sdk/). +Using the legacy `raven-python` client? It's now in maintenance mode, and we recommend migrating to the new SDK for an improved experience. Get all the details in our [migration guide](https://docs.sentry.io/platforms/python/migration/raven-to-sentry-sdk/). -## Contributing to the SDK +## Want to Contribute? -Please refer to [CONTRIBUTING.md](CONTRIBUTING.md). +We’d love your help in improving the Sentry SDK! Whether it’s fixing bugs, adding features, or enhancing documentation, every contribution is valuable. -## Getting Help/Support +For details on how to contribute, please check out [CONTRIBUTING.md](CONTRIBUTING.md) and explore the [open issues](https://github.com/getsentry/sentry-python/issues). -If you need help setting up or configuring the Python SDK (or anything else in the Sentry universe) please head over to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people in our Discord community ready to help you! +## Need Help? + +If you encounter issues or need help setting up or configuring the SDK, don’t hesitate to reach out to the [Sentry Community on Discord](https://discord.com/invite/Ww9hbqr). There is a ton of great people there ready to help! ## Resources -- [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/quickstart/) -- [![Forum](https://img.shields.io/badge/forum-sentry-green.svg)](https://forum.sentry.io/c/sdks) -- [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) -- [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) -- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) +Here are additional resources to help you make the most of Sentry: + +- [![Documentation](https://img.shields.io/badge/documentation-sentry.io-green.svg)](https://docs.sentry.io/quickstart/) – Official documentation to get started. +- [![Discord](https://img.shields.io/discord/621778831602221064)](https://discord.gg/Ww9hbqr) – Join our Discord community. +- [![Twitter Follow](https://img.shields.io/twitter/follow/getsentry?label=getsentry&style=social)](https://twitter.com/intent/follow?screen_name=getsentry) – Follow us on X (Twitter) for updates. +- [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-sentry-green.svg)](http://stackoverflow.com/questions/tagged/sentry) – Questions and answers related to Sentry. ## License -Licensed under the MIT license, see [`LICENSE`](LICENSE) +The SDK is open-source and available under the MIT license. Check out the [LICENSE](LICENSE) file for more information. +--- -### Thanks to all the people who contributed! +Thanks to everyone who has helped improve the SDK! diff --git a/docs/conf.py b/docs/conf.py index 884b977e7f..3ecdbe2e68 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,7 +8,10 @@ import sphinx.builders.latex import sphinx.builders.texinfo import sphinx.builders.text +import sphinx.domains.c # noqa: F401 +import sphinx.domains.cpp # noqa: F401 import sphinx.ext.autodoc # noqa: F401 +import sphinx.ext.intersphinx # noqa: F401 import urllib3.exceptions # noqa: F401 typing.TYPE_CHECKING = True @@ -28,7 +31,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.12.0" +release = "2.19.2" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/mypy.ini b/mypy.ini index bacba96ceb..63fa7f334f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -74,6 +74,8 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-openai.*] ignore_missing_imports = True +[mypy-openfeature.*] +ignore_missing_imports = True [mypy-huggingface_hub.*] ignore_missing_imports = True [mypy-arq.*] diff --git a/pyproject.toml b/pyproject.toml index a2d2e0f7d0..7823c17a7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,16 @@ extend-exclude = ''' | .*_pb2_grpc.py # exclude autogenerated Protocol Buffer files anywhere in the project ) ''' + +[tool.coverage.run] +branch = true +omit = [ + "/tmp/*", + "*/tests/*", + "*/.venv/*", +] + [tool.coverage.report] - exclude_also = [ - "if TYPE_CHECKING:", - ] \ No newline at end of file +exclude_also = [ + "if TYPE_CHECKING:", +] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini index bece12f986..7edd6127b9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,7 @@ [pytest] -addopts = -vvv -rfEs -s --durations=5 --cov=tests --cov=sentry_sdk --cov-branch --cov-report= --tb=short --junitxml=.junitxml +addopts = -vvv -rfEs -s --durations=5 --cov=./sentry_sdk --cov-branch --cov-report= --tb=short --junitxml=.junitxml asyncio_mode = strict +asyncio_default_fixture_loop_scope = function markers = tests_internal_exceptions: Handle internal exceptions just as the SDK does, to test it. (Otherwise internal exceptions are recorded and reraised.) diff --git a/requirements-devenv.txt b/requirements-devenv.txt index 29d3f15ec9..c0fa5cf245 100644 --- a/requirements-devenv.txt +++ b/requirements-devenv.txt @@ -1,5 +1,5 @@ -r requirements-linting.txt -r requirements-testing.txt mockupdb # required by `pymongo` tests that are enabled by `pymongo` from linter requirements -pytest<7.0.0 # https://github.com/pytest-dev/pytest/issues/9621; see tox.ini +pytest pytest-asyncio diff --git a/requirements-docs.txt b/requirements-docs.txt index ed371ed9c9..15f226aac7 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,5 +1,5 @@ gevent shibuya -sphinx==7.2.6 +sphinx sphinx-autodoc-typehints[type_comments]>=1.8.0 typing-extensions diff --git a/requirements-linting.txt b/requirements-linting.txt index 3b88581e24..c3f39ecd1f 100644 --- a/requirements-linting.txt +++ b/requirements-linting.txt @@ -14,3 +14,7 @@ loguru # There is no separate types module. flake8-bugbear pep8-naming pre-commit # local linting +httpcore +openfeature-sdk +launchdarkly-server-sdk +typer diff --git a/requirements-testing.txt b/requirements-testing.txt index 95c015f806..dfbd821845 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -10,4 +10,7 @@ executing asttokens responses pysocks +socksio +httpcore[http2] setuptools +Brotli diff --git a/scripts/init_serverless_sdk.py b/scripts/init_serverless_sdk.py index a4953ca9d7..9b4412c420 100644 --- a/scripts/init_serverless_sdk.py +++ b/scripts/init_serverless_sdk.py @@ -11,9 +11,10 @@ import re import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any diff --git a/scripts/ready_yet/main.py b/scripts/ready_yet/main.py new file mode 100644 index 0000000000..bba97d0c98 --- /dev/null +++ b/scripts/ready_yet/main.py @@ -0,0 +1,124 @@ +import time +import re +import sys + +import requests + +from collections import defaultdict + +from pathlib import Path + +from tox.config.cli.parse import get_options +from tox.session.state import State +from tox.config.sets import CoreConfigSet +from tox.config.source.tox_ini import ToxIni + +PYTHON_VERSION = "3.13" + +MATCH_LIB_SENTRY_REGEX = r"py[\d\.]*-(.*)-.*" + +PYPI_PROJECT_URL = "https://pypi.python.org/pypi/{project}/json" +PYPI_VERSION_URL = "https://pypi.python.org/pypi/{project}/{version}/json" + + +def get_tox_envs(tox_ini_path: Path) -> list: + tox_ini = ToxIni(tox_ini_path) + conf = State(get_options(), []).conf + tox_section = next(tox_ini.sections()) + core_config_set = CoreConfigSet( + conf, tox_section, tox_ini_path.parent, tox_ini_path + ) + ( + core_config_set.loaders.extend( + tox_ini.get_loaders( + tox_section, + base=[], + override_map=defaultdict(list, {}), + conf=core_config_set, + ) + ) + ) + return core_config_set.load("env_list") + + +def get_libs(tox_ini: Path, regex: str) -> set: + libs = set() + for env in get_tox_envs(tox_ini): + match = re.match(regex, env) + if match: + libs.add(match.group(1)) + + return sorted(libs) + + +def main(): + """ + Check if libraries in our tox.ini are ready for Python version defined in `PYTHON_VERSION`. + """ + print(f"Checking libs from tox.ini for Python {PYTHON_VERSION} compatibility:") + + ready = set() + not_ready = set() + not_found = set() + + tox_ini = Path(__file__).parent.parent.parent.joinpath("tox.ini") + + libs = get_libs(tox_ini, MATCH_LIB_SENTRY_REGEX) + + for lib in libs: + print(".", end="") + sys.stdout.flush() + + # Get latest version of lib + url = PYPI_PROJECT_URL.format(project=lib) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + not_found.add(lib) + continue + + latest_version = pypi_data.json()["info"]["version"] + + # Get supported Python version of latest version of lib + url = PYPI_PROJECT_URL.format(project=lib, version=latest_version) + pypi_data = requests.get(url) + + if pypi_data.status_code != 200: + continue + + classifiers = pypi_data.json()["info"]["classifiers"] + + if f"Programming Language :: Python :: {PYTHON_VERSION}" in classifiers: + ready.add(lib) + else: + not_ready.add(lib) + + # cut pypi some slack + time.sleep(0.1) + + # Print report + print("\n") + print(f"\nReady for Python {PYTHON_VERSION}:") + if len(ready) == 0: + print("- None ") + + for x in sorted(ready): + print(f"- {x}") + + print(f"\nNOT ready for Python {PYTHON_VERSION}:") + if len(not_ready) == 0: + print("- None ") + + for x in sorted(not_ready): + print(f"- {x}") + + print("\nNot found on PyPI:") + if len(not_found) == 0: + print("- None ") + + for x in sorted(not_found): + print(f"- {x}") + + +if __name__ == "__main__": + main() diff --git a/scripts/ready_yet/requirements.txt b/scripts/ready_yet/requirements.txt new file mode 100644 index 0000000000..69f9472fa5 --- /dev/null +++ b/scripts/ready_yet/requirements.txt @@ -0,0 +1,2 @@ +requests +tox diff --git a/scripts/ready_yet/run.sh b/scripts/ready_yet/run.sh new file mode 100755 index 0000000000..f32bd7bdda --- /dev/null +++ b/scripts/ready_yet/run.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# exit on first error +set -xe + +reset + +# create and activate virtual environment +python -m venv .venv +source .venv/bin/activate + +# Install (or update) requirements +python -m pip install -r requirements.txt + +# Run the script +python main.py \ No newline at end of file diff --git a/scripts/split-tox-gh-actions/split-tox-gh-actions.py b/scripts/split-tox-gh-actions/split-tox-gh-actions.py index b9f978d850..26d13390c2 100755 --- a/scripts/split-tox-gh-actions/split-tox-gh-actions.py +++ b/scripts/split-tox-gh-actions/split-tox-gh-actions.py @@ -65,26 +65,28 @@ "openai", "huggingface_hub", ], - "AWS Lambda": [ + "AWS": [ # this is separate from Cloud Computing because only this one test suite # needs to run with access to GitHub secrets "aws_lambda", ], - "Cloud Computing": [ + "Cloud": [ "boto3", "chalice", "cloud_resource_context", "gcp", ], - "Data Processing": [ + "Tasks": [ "arq", "beam", "celery", + "dramatiq", "huey", + "ray", "rq", "spark", ], - "Databases": [ + "DBs": [ "asyncpg", "clickhouse_driver", "pymongo", @@ -98,19 +100,19 @@ "graphene", "strawberry", ], - "Networking": [ + "Network": [ "gevent", "grpc", "httpx", "requests", ], - "Web Frameworks 1": [ + "Web 1": [ "django", "flask", "starlette", "fastapi", ], - "Web Frameworks 2": [ + "Web 2": [ "aiohttp", "asgi", "bottle", @@ -122,12 +124,15 @@ "starlite", "tornado", ], - "Miscellaneous": [ + "Misc": [ + "launchdarkly", "loguru", + "openfeature", "opentelemetry", "potel", "pure_eval", "trytond", + "typer", ], } diff --git a/scripts/split-tox-gh-actions/templates/base.jinja b/scripts/split-tox-gh-actions/templates/base.jinja index 0a27bb0b8d..23f051de42 100644 --- a/scripts/split-tox-gh-actions/templates/base.jinja +++ b/scripts/split-tox-gh-actions/templates/base.jinja @@ -1,3 +1,6 @@ +# Do not edit this file. This file is generated automatically by executing +# python scripts/split-tox-gh-actions/split-tox-gh-actions.py + {% with lowercase_group=group | replace(" ", "_") | lower %} name: Test {{ group }} diff --git a/scripts/split-tox-gh-actions/templates/check_permissions.jinja b/scripts/split-tox-gh-actions/templates/check_permissions.jinja index 4c418cd67a..390f447856 100644 --- a/scripts/split-tox-gh-actions/templates/check_permissions.jinja +++ b/scripts/split-tox-gh-actions/templates/check_permissions.jinja @@ -2,7 +2,7 @@ name: permissions check runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 with: persist-credentials: false diff --git a/scripts/split-tox-gh-actions/templates/check_required.jinja b/scripts/split-tox-gh-actions/templates/check_required.jinja index b9b0f54015..ddb47cddf1 100644 --- a/scripts/split-tox-gh-actions/templates/check_required.jinja +++ b/scripts/split-tox-gh-actions/templates/check_required.jinja @@ -1,5 +1,5 @@ check_required_tests: - name: All {{ group }} tests passed + name: All pinned {{ group }} tests passed {% if "pinned" in categories %} needs: test-{{ group | replace(" ", "_") | lower }}-pinned {% endif %} diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index 43d7081446..7225bbbfe5 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -39,7 +39,7 @@ {% endif %} steps: - - uses: actions/checkout@v4.1.7 + - uses: actions/checkout@v4.2.2 {% if needs_github_secrets %} {% raw %} with: @@ -51,7 +51,7 @@ python-version: {% raw %}${{ matrix.python-version }}{% endraw %} allow-prereleases: true {% if needs_clickhouse %} - - uses: getsentry/action-clickhouse-in-ci@v1 + - uses: getsentry/action-clickhouse-in-ci@v1.1 {% endif %} {% if needs_redis %} @@ -77,22 +77,33 @@ {% endif %} {% endfor %} + - name: Generate coverage XML (Python 3.6) + if: {% raw %}${{ !cancelled() && matrix.python-version == '3.6' }}{% endraw %} + run: | + export COVERAGE_RCFILE=.coveragerc36 + coverage combine .coverage-sentry-* + coverage xml --ignore-errors + - name: Generate coverage XML - if: {% raw %}${{ !cancelled() }}{% endraw %} + if: {% raw %}${{ !cancelled() && matrix.python-version != '3.6' }}{% endraw %} run: | - coverage combine .coverage* - coverage xml -i + coverage combine .coverage-sentry-* + coverage xml - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v5.1.1 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml + # make sure no plugins alter our coverage reports + plugin: noop + verbose: true - name: Upload test results to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} uses: codecov/test-results-action@v1 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} - files: .junitxml \ No newline at end of file + files: .junitxml + verbose: true diff --git a/sentry_sdk/_compat.py b/sentry_sdk/_compat.py index f7fd6903a4..a811cf2120 100644 --- a/sentry_sdk/_compat.py +++ b/sentry_sdk/_compat.py @@ -1,6 +1,6 @@ import sys -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -10,6 +10,7 @@ PY37 = sys.version_info[0] == 3 and sys.version_info[1] >= 7 +PY38 = sys.version_info[0] == 3 and sys.version_info[1] >= 8 PY310 = sys.version_info[0] == 3 and sys.version_info[1] >= 10 PY311 = sys.version_info[0] == 3 and sys.version_info[1] >= 11 diff --git a/sentry_sdk/_init_implementation.py b/sentry_sdk/_init_implementation.py index 256a69ee83..eb02b3d11e 100644 --- a/sentry_sdk/_init_implementation.py +++ b/sentry_sdk/_init_implementation.py @@ -1,3 +1,5 @@ +import warnings + from typing import TYPE_CHECKING import sentry_sdk @@ -9,16 +11,35 @@ class _InitGuard: + _CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE = ( + "Using the return value of sentry_sdk.init as a context manager " + "and manually calling the __enter__ and __exit__ methods on the " + "return value are deprecated. We are no longer maintaining this " + "functionality, and we will remove it in the next major release." + ) + def __init__(self, client): # type: (sentry_sdk.Client) -> None self._client = client def __enter__(self): # type: () -> _InitGuard + warnings.warn( + self._CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE, + stacklevel=2, + category=DeprecationWarning, + ) + return self def __exit__(self, exc_type, exc_value, tb): # type: (Any, Any, Any) -> None + warnings.warn( + self._CONTEXT_MANAGER_DEPRECATION_WARNING_MESSAGE, + stacklevel=2, + category=DeprecationWarning, + ) + c = self._client if c is not None: c.close() diff --git a/sentry_sdk/_lru_cache.py b/sentry_sdk/_lru_cache.py index 37e86e5fe3..825c773529 100644 --- a/sentry_sdk/_lru_cache.py +++ b/sentry_sdk/_lru_cache.py @@ -62,6 +62,8 @@ """ +from copy import copy, deepcopy + SENTINEL = object() @@ -89,6 +91,13 @@ def __init__(self, max_size): self.hits = self.misses = 0 + def __copy__(self): + cache = LRUCache(self.max_size) + cache.full = self.full + cache.cache = copy(self.cache) + cache.root = deepcopy(self.root) + return cache + def set(self, key, value): link = self.cache.get(key, SENTINEL) @@ -154,3 +163,19 @@ def get(self, key, default=None): self.hits += 1 return link[VALUE] + + def get_all(self): + nodes = [] + node = self.root[NEXT] + + # To ensure the loop always terminates we iterate to the maximum + # size of the LRU cache. + for _ in range(self.max_size): + # The cache may not be full. We exit early if we've wrapped + # around to the head. + if node is self.root: + break + nodes.append((node[KEY], node[VALUE])) + node = node[NEXT] + + return nodes diff --git a/sentry_sdk/_queue.py b/sentry_sdk/_queue.py index 056d576fbe..c0410d1f92 100644 --- a/sentry_sdk/_queue.py +++ b/sentry_sdk/_queue.py @@ -76,7 +76,7 @@ from collections import deque from time import time -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 5255fcb0fa..4e3c195cc6 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -1,7 +1,4 @@ -try: - from typing import TYPE_CHECKING -except ImportError: - TYPE_CHECKING = False +from typing import TYPE_CHECKING # Re-exported for compat, since code out there in the wild might use this variable. diff --git a/sentry_sdk/_werkzeug.py b/sentry_sdk/_werkzeug.py index 3f6b6b06a4..0fa3d611f1 100644 --- a/sentry_sdk/_werkzeug.py +++ b/sentry_sdk/_werkzeug.py @@ -32,7 +32,7 @@ SUCH DAMAGE. """ -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Dict diff --git a/sentry_sdk/ai/monitoring.py b/sentry_sdk/ai/monitoring.py index b8f6a8c79a..860833b8f5 100644 --- a/sentry_sdk/ai/monitoring.py +++ b/sentry_sdk/ai/monitoring.py @@ -5,7 +5,8 @@ from sentry_sdk import start_span from sentry_sdk.tracing import Span from sentry_sdk.utils import ContextVar -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional, Callable, Any @@ -32,7 +33,7 @@ def sync_wrapped(*args, **kwargs): curr_pipeline = _ai_pipeline_name.get() op = span_kwargs.get("op", "ai.run" if curr_pipeline else "ai.pipeline") - with start_span(description=description, op=op, **span_kwargs) as span: + with start_span(name=description, op=op, **span_kwargs) as span: for k, v in kwargs.pop("sentry_tags", {}).items(): span.set_tag(k, v) for k, v in kwargs.pop("sentry_data", {}).items(): @@ -61,7 +62,7 @@ async def async_wrapped(*args, **kwargs): curr_pipeline = _ai_pipeline_name.get() op = span_kwargs.get("op", "ai.run" if curr_pipeline else "ai.pipeline") - with start_span(description=description, op=op, **span_kwargs) as span: + with start_span(name=description, op=op, **span_kwargs) as span: for k, v in kwargs.pop("sentry_tags", {}).items(): span.set_tag(k, v) for k, v in kwargs.pop("sentry_data", {}).items(): diff --git a/sentry_sdk/ai/utils.py b/sentry_sdk/ai/utils.py index 42d46304e4..ed3494f679 100644 --- a/sentry_sdk/ai/utils.py +++ b/sentry_sdk/ai/utils.py @@ -1,4 +1,4 @@ -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/api.py b/sentry_sdk/api.py index 3c0876382c..d60434079c 100644 --- a/sentry_sdk/api.py +++ b/sentry_sdk/api.py @@ -9,8 +9,7 @@ from sentry_sdk.tracing import NoOpSpan, Transaction, trace from sentry_sdk.crons import monitor - -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Mapping diff --git a/sentry_sdk/attachments.py b/sentry_sdk/attachments.py index 649c4f175b..e5404f8658 100644 --- a/sentry_sdk/attachments.py +++ b/sentry_sdk/attachments.py @@ -1,9 +1,10 @@ import os import mimetypes -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.envelope import Item, PayloadRef +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional, Union, Callable diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 6698ee527d..db2cc19110 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -5,12 +5,14 @@ from collections.abc import Mapping from datetime import datetime, timezone from importlib import import_module +from typing import cast, overload from sentry_sdk._compat import PY37, check_uwsgi_thread_support from sentry_sdk.utils import ( + ContextVar, capture_internal_exceptions, current_stacktrace, - disable_capture_event, + env_to_bool, format_timestamp, get_sdk_name, get_type_name, @@ -21,7 +23,7 @@ ) from sentry_sdk.serializer import serialize from sentry_sdk.tracing import trace -from sentry_sdk.transport import HttpTransport, make_transport +from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( DEFAULT_MAX_VALUE_LENGTH, DEFAULT_OPTIONS, @@ -30,7 +32,6 @@ ClientConstructor, ) from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations -from sentry_sdk.utils import ContextVar from sentry_sdk.sessions import SessionFlusher from sentry_sdk.envelope import Envelope from sentry_sdk.profiler.continuous_profiler import setup_continuous_profiler @@ -43,7 +44,7 @@ from sentry_sdk.monitor import Monitor from sentry_sdk.spotlight import setup_spotlight -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -53,14 +54,17 @@ from typing import Sequence from typing import Type from typing import Union + from typing import TypeVar from sentry_sdk._types import Event, Hint, SDKInfo from sentry_sdk.integrations import Integration from sentry_sdk.metrics import MetricsAggregator from sentry_sdk.scope import Scope from sentry_sdk.session import Session + from sentry_sdk.spotlight import SpotlightClient from sentry_sdk.transport import Transport + I = TypeVar("I", bound=Integration) # noqa: E741 _client_init_debug = ContextVar("client_init_debug") @@ -104,11 +108,7 @@ def _get_options(*args, **kwargs): rv["environment"] = os.environ.get("SENTRY_ENVIRONMENT") or "production" if rv["debug"] is None: - rv["debug"] = os.environ.get("SENTRY_DEBUG", "False").lower() in ( - "true", - "1", - "t", - ) + rv["debug"] = env_to_bool(os.environ.get("SENTRY_DEBUG", "False"), strict=True) if rv["server_name"] is None and hasattr(socket, "gethostname"): rv["server_name"] = socket.gethostname() @@ -128,7 +128,11 @@ def _get_options(*args, **kwargs): rv["traces_sample_rate"] = 1.0 if rv["event_scrubber"] is None: - rv["event_scrubber"] = EventScrubber() + rv["event_scrubber"] = EventScrubber( + send_default_pii=( + False if rv["send_default_pii"] is None else rv["send_default_pii"] + ) + ) if rv["socket_options"] and not isinstance(rv["socket_options"], list): logger.warning( @@ -154,6 +158,8 @@ class BaseClient: The basic definition of a client that is used for sending data to Sentry. """ + spotlight = None # type: Optional[SpotlightClient] + def __init__(self, options=None): # type: (Optional[Dict[str, Any]]) -> None self.options = ( @@ -198,8 +204,20 @@ def capture_session(self, *args, **kwargs): # type: (*Any, **Any) -> None return None - def get_integration(self, *args, **kwargs): - # type: (*Any, **Any) -> Any + if TYPE_CHECKING: + + @overload + def get_integration(self, name_or_class): + # type: (str) -> Optional[Integration] + ... + + @overload + def get_integration(self, name_or_class): + # type: (type[I]) -> Optional[I] + ... + + def get_integration(self, name_or_class): + # type: (Union[str, type[Integration]]) -> Optional[Integration] return None def close(self, *args, **kwargs): @@ -374,7 +392,16 @@ def _capture_envelope(envelope): disabled_integrations=self.options["disabled_integrations"], ) - self.spotlight = None + spotlight_config = self.options.get("spotlight") + if spotlight_config is None and "SENTRY_SPOTLIGHT" in os.environ: + spotlight_env_value = os.environ["SENTRY_SPOTLIGHT"] + spotlight_config = env_to_bool(spotlight_env_value, strict=True) + self.options["spotlight"] = ( + spotlight_config + if spotlight_config is not None + else spotlight_env_value + ) + if self.options.get("spotlight"): self.spotlight = setup_spotlight(self.options) @@ -406,7 +433,7 @@ def _capture_envelope(envelope): self.monitor or self.metrics_aggregator or has_profiling_enabled(self.options) - or isinstance(self.transport, HttpTransport) + or isinstance(self.transport, BaseHttpTransport) ): # If we have anything on that could spawn a background thread, we # need to check if it's safe to use them. @@ -428,7 +455,11 @@ def should_send_default_pii(self): Returns whether the client should send default PII (Personally Identifiable Information) data to Sentry. """ - return self.options.get("send_default_pii", False) + result = self.options.get("send_default_pii") + if result is None: + result = not self.options["dsn"] and self.spotlight is not None + + return result @property def dsn(self): @@ -519,16 +550,20 @@ def _prepare_event( if event is not None: event_scrubber = self.options["event_scrubber"] - if event_scrubber and not self.options["send_default_pii"]: + if event_scrubber: event_scrubber.scrub_event(event) # Postprocess the event here so that annotated types do # generally not surface in before_send if event is not None: - event = serialize( - event, - max_request_body_size=self.options.get("max_request_body_size"), - max_value_length=self.options.get("max_value_length"), + event = cast( + "Event", + serialize( + cast("Dict[str, Any]", event), + max_request_body_size=self.options.get("max_request_body_size"), + max_value_length=self.options.get("max_value_length"), + custom_repr=self.options.get("custom_repr"), + ), ) before_send = self.options["before_send"] @@ -726,21 +761,16 @@ def capture_event( :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ - if disable_capture_event.get(False): - return None - - if hint is None: - hint = {} - event_id = event.get("event_id") hint = dict(hint or ()) # type: Hint - if event_id is None: - event["event_id"] = event_id = uuid.uuid4().hex if not self._should_capture(event, hint, scope): return None profile = event.pop("profile", None) + event_id = event.get("event_id") + if event_id is None: + event["event_id"] = event_id = uuid.uuid4().hex event_opt = self._prepare_event(event, hint, scope) if event_opt is None: return None @@ -788,15 +818,16 @@ def capture_event( for attachment in attachments or (): envelope.add_item(attachment.to_envelope_item()) + return_value = None if self.spotlight: self.spotlight.capture_envelope(envelope) + return_value = event_id - if self.transport is None: - return None - - self.transport.capture_envelope(envelope) + if self.transport is not None: + self.transport.capture_envelope(envelope) + return_value = event_id - return event_id + return return_value def capture_session( self, session # type: Session @@ -807,10 +838,22 @@ def capture_session( else: self.session_flusher.add_session(session) + if TYPE_CHECKING: + + @overload + def get_integration(self, name_or_class): + # type: (str) -> Optional[Integration] + ... + + @overload + def get_integration(self, name_or_class): + # type: (type[I]) -> Optional[I] + ... + def get_integration( self, name_or_class # type: Union[str, Type[Integration]] ): - # type: (...) -> Any + # type: (...) -> Optional[Integration] """Returns the integration for this client by name or class. If the client does not have that integration then `None` is returned. """ @@ -873,7 +916,7 @@ def __exit__(self, exc_type, exc_value, tb): self.close() -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: # Make mypy, PyCharm and other static analyzers think `get_options` is a diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index b50a2843a6..0bb71cb98d 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1,11 +1,14 @@ import itertools from enum import Enum -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING # up top to prevent circular import due to integration import DEFAULT_MAX_VALUE_LENGTH = 1024 +DEFAULT_MAX_STACK_FRAMES = 100 +DEFAULT_ADD_FULL_STACK = False + # Also needs to be at the top to prevent circular import class EndpointType(Enum): @@ -18,6 +21,11 @@ class EndpointType(Enum): ENVELOPE = "envelope" +class CompressionAlgo(Enum): + GZIP = "gzip" + BROTLI = "br" + + if TYPE_CHECKING: import sentry_sdk @@ -53,14 +61,17 @@ class EndpointType(Enum): Experiments = TypedDict( "Experiments", { - "attach_explain_plans": dict[str, Any], "max_spans": Optional[int], + "max_flags": Optional[int], "record_sql_params": Optional[bool], "continuous_profiling_auto_start": Optional[bool], "continuous_profiling_mode": Optional[ContinuousProfilerMode], "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], + "transport_compression_level": Optional[int], + "transport_compression_algo": Optional[CompressionAlgo], "transport_num_pools": Optional[int], + "transport_http2": Optional[bool], "enable_metrics": Optional[bool], "before_emit_metric": Optional[ Callable[[str, MetricValue, MeasurementUnit, MetricTags], bool] @@ -465,6 +476,8 @@ class OP: QUEUE_TASK_RQ = "queue.task.rq" QUEUE_SUBMIT_HUEY = "queue.submit.huey" QUEUE_TASK_HUEY = "queue.task.huey" + QUEUE_SUBMIT_RAY = "queue.submit.ray" + QUEUE_TASK_RAY = "queue.task.ray" SUBPROCESS = "subprocess" SUBPROCESS_WAIT = "subprocess.wait" SUBPROCESS_COMMUNICATE = "subprocess.communicate" @@ -479,6 +492,7 @@ class OP: # This type exists to trick mypy and PyCharm into thinking `init` and `Client` # take these arguments (even though they take opaque **kwargs) class ClientConstructor: + def __init__( self, dsn=None, # type: Optional[str] @@ -496,7 +510,7 @@ def __init__( transport=None, # type: Optional[Union[sentry_sdk.transport.Transport, Type[sentry_sdk.transport.Transport], Callable[[Event], None]]] transport_queue_size=DEFAULT_QUEUE_SIZE, # type: int sample_rate=1.0, # type: float - send_default_pii=False, # type: bool + send_default_pii=None, # type: Optional[bool] http_proxy=None, # type: Optional[str] https_proxy=None, # type: Optional[str] ignore_errors=[], # type: Sequence[Union[type, str]] # noqa: B006 @@ -539,6 +553,9 @@ def __init__( spotlight=None, # type: Optional[Union[bool, str]] cert_file=None, # type: Optional[str] key_file=None, # type: Optional[str] + custom_repr=None, # type: Optional[Callable[..., Optional[str]]] + add_full_stack=DEFAULT_ADD_FULL_STACK, # type: bool + max_stack_frames=DEFAULT_MAX_STACK_FRAMES, # type: Optional[int] ): # type: (...) -> None pass @@ -564,4 +581,4 @@ def _get_default_options(): del _get_default_options -VERSION = "2.12.0" +VERSION = "2.19.2" diff --git a/sentry_sdk/crons/api.py b/sentry_sdk/crons/api.py index 7f27df9b3a..20e95685a7 100644 --- a/sentry_sdk/crons/api.py +++ b/sentry_sdk/crons/api.py @@ -1,8 +1,8 @@ import uuid import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/crons/decorator.py b/sentry_sdk/crons/decorator.py index 885d42e0e1..9af00e61c0 100644 --- a/sentry_sdk/crons/decorator.py +++ b/sentry_sdk/crons/decorator.py @@ -1,11 +1,12 @@ from functools import wraps from inspect import iscoroutinefunction -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.crons import capture_checkin from sentry_sdk.crons.consts import MonitorStatus from sentry_sdk.utils import now +from typing import TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Awaitable, Callable from types import TracebackType diff --git a/sentry_sdk/db/explain_plan/__init__.py b/sentry_sdk/db/explain_plan/__init__.py deleted file mode 100644 index 39b0e7ba8f..0000000000 --- a/sentry_sdk/db/explain_plan/__init__.py +++ /dev/null @@ -1,60 +0,0 @@ -from datetime import datetime, timedelta, timezone - -from sentry_sdk.consts import TYPE_CHECKING - -if TYPE_CHECKING: - from typing import Any - - -EXPLAIN_CACHE = {} -EXPLAIN_CACHE_SIZE = 50 -EXPLAIN_CACHE_TIMEOUT_SECONDS = 60 * 60 * 24 - - -def cache_statement(statement, options): - # type: (str, dict[str, Any]) -> None - global EXPLAIN_CACHE - - now = datetime.now(timezone.utc) - explain_cache_timeout_seconds = options.get( - "explain_cache_timeout_seconds", EXPLAIN_CACHE_TIMEOUT_SECONDS - ) - expiration_time = now + timedelta(seconds=explain_cache_timeout_seconds) - - EXPLAIN_CACHE[hash(statement)] = expiration_time - - -def remove_expired_cache_items(): - # type: () -> None - """ - Remove expired cache items from the cache. - """ - global EXPLAIN_CACHE - - now = datetime.now(timezone.utc) - - for key, expiration_time in EXPLAIN_CACHE.items(): - expiration_in_the_past = expiration_time < now - if expiration_in_the_past: - del EXPLAIN_CACHE[key] - - -def should_run_explain_plan(statement, options): - # type: (str, dict[str, Any]) -> bool - """ - Check cache if the explain plan for the given statement should be run. - """ - global EXPLAIN_CACHE - - remove_expired_cache_items() - - key = hash(statement) - if key in EXPLAIN_CACHE: - return False - - explain_cache_size = options.get("explain_cache_size", EXPLAIN_CACHE_SIZE) - cache_is_full = len(EXPLAIN_CACHE.keys()) >= explain_cache_size - if cache_is_full: - return False - - return True diff --git a/sentry_sdk/db/explain_plan/django.py b/sentry_sdk/db/explain_plan/django.py deleted file mode 100644 index b395f1c82b..0000000000 --- a/sentry_sdk/db/explain_plan/django.py +++ /dev/null @@ -1,47 +0,0 @@ -from sentry_sdk.consts import TYPE_CHECKING -from sentry_sdk.db.explain_plan import cache_statement, should_run_explain_plan - -if TYPE_CHECKING: - from typing import Any - from typing import Callable - - from sentry_sdk.tracing import Span - - -def attach_explain_plan_to_span( - span, connection, statement, parameters, mogrify, options -): - # type: (Span, Any, str, Any, Callable[[str, Any], bytes], dict[str, Any]) -> None - """ - Run EXPLAIN or EXPLAIN ANALYZE on the given statement and attach the explain plan to the span data. - - Usage: - ``` - sentry_sdk.init( - dsn="...", - _experiments={ - "attach_explain_plans": { - "explain_cache_size": 1000, # Run explain plan for the 1000 most run queries - "explain_cache_timeout_seconds": 60 * 60 * 24, # Run the explain plan for each statement only every 24 hours - "use_explain_analyze": True, # Run "explain analyze" instead of only "explain" - } - } - ``` - """ - if not statement.strip().upper().startswith("SELECT"): - return - - if not should_run_explain_plan(statement, options): - return - - analyze = "ANALYZE" if options.get("use_explain_analyze", False) else "" - explain_statement = ("EXPLAIN %s " % analyze) + mogrify( - statement, parameters - ).decode("utf-8") - - with connection.cursor() as cursor: - cursor.execute(explain_statement) - explain_plan = [row for row in cursor.fetchall()] - - span.set_data("db.explain_plan", explain_plan) - cache_statement(statement, options) diff --git a/sentry_sdk/db/explain_plan/sqlalchemy.py b/sentry_sdk/db/explain_plan/sqlalchemy.py deleted file mode 100644 index 1ca451e808..0000000000 --- a/sentry_sdk/db/explain_plan/sqlalchemy.py +++ /dev/null @@ -1,47 +0,0 @@ -from sentry_sdk.consts import TYPE_CHECKING -from sentry_sdk.db.explain_plan import cache_statement, should_run_explain_plan -from sentry_sdk.integrations import DidNotEnable - -try: - from sqlalchemy.sql import text # type: ignore -except ImportError: - raise DidNotEnable("SQLAlchemy not installed.") - -if TYPE_CHECKING: - from typing import Any - - from sentry_sdk.tracing import Span - - -def attach_explain_plan_to_span(span, connection, statement, parameters, options): - # type: (Span, Any, str, Any, dict[str, Any]) -> None - """ - Run EXPLAIN or EXPLAIN ANALYZE on the given statement and attach the explain plan to the span data. - - Usage: - ``` - sentry_sdk.init( - dsn="...", - _experiments={ - "attach_explain_plans": { - "explain_cache_size": 1000, # Run explain plan for the 1000 most run queries - "explain_cache_timeout_seconds": 60 * 60 * 24, # Run the explain plan for each statement only every 24 hours - "use_explain_analyze": True, # Run "explain analyze" instead of only "explain" - } - } - ``` - """ - if not statement.strip().upper().startswith("SELECT"): - return - - if not should_run_explain_plan(statement, options): - return - - analyze = "ANALYZE" if options.get("use_explain_analyze", False) else "" - explain_statement = (("EXPLAIN %s " % analyze) + statement) % parameters - - result = connection.execute(text(explain_statement)) - explain_plan = [row for row in result] - - span.set_data("db.explain_plan", explain_plan) - cache_statement(statement, options) diff --git a/sentry_sdk/envelope.py b/sentry_sdk/envelope.py index 6bb1eb22c7..760116daa1 100644 --- a/sentry_sdk/envelope.py +++ b/sentry_sdk/envelope.py @@ -2,10 +2,11 @@ import json import mimetypes -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.session import Session from sentry_sdk.utils import json_dumps, capture_internal_exceptions +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Optional @@ -259,7 +260,7 @@ def type(self): def data_category(self): # type: (...) -> EventDataCategory ty = self.headers.get("type") - if ty == "session": + if ty == "session" or ty == "sessions": return "session" elif ty == "attachment": return "attachment" diff --git a/sentry_sdk/flag_utils.py b/sentry_sdk/flag_utils.py new file mode 100644 index 0000000000..2b345a7f0b --- /dev/null +++ b/sentry_sdk/flag_utils.py @@ -0,0 +1,47 @@ +from copy import copy +from typing import TYPE_CHECKING + +import sentry_sdk +from sentry_sdk._lru_cache import LRUCache + +if TYPE_CHECKING: + from typing import TypedDict, Optional + from sentry_sdk._types import Event, ExcInfo + + FlagData = TypedDict("FlagData", {"flag": str, "result": bool}) + + +DEFAULT_FLAG_CAPACITY = 100 + + +class FlagBuffer: + + def __init__(self, capacity): + # type: (int) -> None + self.buffer = LRUCache(capacity) + self.capacity = capacity + + def clear(self): + # type: () -> None + self.buffer = LRUCache(self.capacity) + + def __copy__(self): + # type: () -> FlagBuffer + buffer = FlagBuffer(capacity=self.capacity) + buffer.buffer = copy(self.buffer) + return buffer + + def get(self): + # type: () -> list[FlagData] + return [{"flag": key, "result": value} for key, value in self.buffer.get_all()] + + def set(self, flag, result): + # type: (str, bool) -> None + self.buffer.set(flag, result) + + +def flag_error_processor(event, exc_info): + # type: (Event, ExcInfo) -> Optional[Event] + scope = sentry_sdk.get_current_scope() + event["contexts"]["flags"] = {"values": scope.flags.get()} + return event diff --git a/sentry_sdk/hub.py b/sentry_sdk/hub.py index 7d81d69541..7fda9202df 100644 --- a/sentry_sdk/hub.py +++ b/sentry_sdk/hub.py @@ -22,7 +22,7 @@ ContextVar, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -101,7 +101,7 @@ def current(cls): rv = _local.get(None) if rv is None: with _suppress_hub_deprecation_warning(): - # This will raise a deprecation warning; supress it since we already warned above. + # This will raise a deprecation warning; suppress it since we already warned above. rv = Hub(GLOBAL_HUB) _local.set(rv) return rv diff --git a/sentry_sdk/integrations/__init__.py b/sentry_sdk/integrations/__init__.py index 3c43ed5472..12336a939b 100644 --- a/sentry_sdk/integrations/__init__.py +++ b/sentry_sdk/integrations/__init__.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod from threading import Lock -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import logger +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence @@ -14,6 +14,10 @@ from typing import Optional from typing import Set from typing import Type + from typing import Union + + +_DEFAULT_FAILED_REQUEST_STATUS_CODES = frozenset(range(500, 600)) _installer_lock = Lock() @@ -91,6 +95,7 @@ def iter_default_integrations(with_auto_enabling_integrations): "sentry_sdk.integrations.huey.HueyIntegration", "sentry_sdk.integrations.huggingface_hub.HuggingfaceHubIntegration", "sentry_sdk.integrations.langchain.LangchainIntegration", + "sentry_sdk.integrations.litestar.LitestarIntegration", "sentry_sdk.integrations.loguru.LoguruIntegration", "sentry_sdk.integrations.openai.OpenAIIntegration", "sentry_sdk.integrations.pymongo.PyMongoIntegration", @@ -121,7 +126,7 @@ def setup_integrations( with_auto_enabling_integrations=False, disabled_integrations=None, ): - # type: (Sequence[Integration], bool, bool, Optional[Sequence[Integration]]) -> Dict[str, Integration] + # type: (Sequence[Integration], bool, bool, Optional[Sequence[Union[type[Integration], Integration]]]) -> Dict[str, Integration] """ Given a list of integration instances, this installs them all. diff --git a/sentry_sdk/integrations/_asgi_common.py b/sentry_sdk/integrations/_asgi_common.py index a099b42e32..c16bbbcfe8 100644 --- a/sentry_sdk/integrations/_asgi_common.py +++ b/sentry_sdk/integrations/_asgi_common.py @@ -2,7 +2,8 @@ from sentry_sdk.scope import should_send_default_pii from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/_wsgi_common.py b/sentry_sdk/integrations/_wsgi_common.py index eeb8ee6136..7266a91f56 100644 --- a/sentry_sdk/integrations/_wsgi_common.py +++ b/sentry_sdk/integrations/_wsgi_common.py @@ -1,20 +1,22 @@ +from contextlib import contextmanager import json from copy import deepcopy import sentry_sdk from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import AnnotatedValue, logger -from sentry_sdk._types import TYPE_CHECKING try: from django.http.request import RawPostDataException except ImportError: RawPostDataException = None +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Dict + from typing import Iterator from typing import Mapping from typing import MutableMapping from typing import Optional @@ -37,6 +39,25 @@ x[len("HTTP_") :] for x in SENSITIVE_ENV_KEYS if x.startswith("HTTP_") ) +DEFAULT_HTTP_METHODS_TO_CAPTURE = ( + "CONNECT", + "DELETE", + "GET", + # "HEAD", # do not capture HEAD requests by default + # "OPTIONS", # do not capture OPTIONS requests by default + "PATCH", + "POST", + "PUT", + "TRACE", +) + + +# This noop context manager can be replaced with "from contextlib import nullcontext" when we drop Python 3.6 support +@contextmanager +def nullcontext(): + # type: () -> Iterator[None] + yield + def request_body_within_bounds(client, content_length): # type: (Optional[sentry_sdk.client.BaseClient], int) -> bool @@ -152,7 +173,13 @@ def json(self): if not self.is_json(): return None - raw_data = self.raw_data() + try: + raw_data = self.raw_data() + except (RawPostDataException, ValueError): + # The body might have already been read, in which case this will + # fail + raw_data = None + if raw_data is None: return None @@ -204,7 +231,7 @@ def _filter_headers(headers): def _in_http_status_code_range(code, code_ranges): - # type: (int, list[HttpStatusCodeRange]) -> bool + # type: (object, list[HttpStatusCodeRange]) -> bool for target in code_ranges: if isinstance(target, int): if code == target: @@ -220,3 +247,18 @@ def _in_http_status_code_range(code, code_ranges): ) return False + + +class HttpCodeRangeContainer: + """ + Wrapper to make it possible to use list[HttpStatusCodeRange] as a Container[int]. + Used for backwards compatibility with the old `failed_request_status_codes` option. + """ + + def __init__(self, code_ranges): + # type: (list[HttpStatusCodeRange]) -> None + self._code_ranges = code_ranges + + def __contains__(self, item): + # type: (object) -> bool + return _in_http_status_code_range(item, self._code_ranges) diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index 6da340f31c..d0226bc156 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -1,12 +1,17 @@ import sys import weakref +from functools import wraps import sentry_sdk from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP, SPANSTATUS, SPANDATA -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + _DEFAULT_FAILED_REQUEST_STATUS_CODES, + Integration, + DidNotEnable, +) from sentry_sdk.integrations.logging import ignore_logger -from sentry_sdk.sessions import auto_session_tracking_scope +from sentry_sdk.sessions import track_session from sentry_sdk.integrations._wsgi_common import ( _filter_headers, request_body_within_bounds, @@ -41,12 +46,14 @@ except ImportError: raise DidNotEnable("AIOHTTP not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from aiohttp.web_request import Request from aiohttp.web_urldispatcher import UrlMappingMatchInfo from aiohttp import TraceRequestStartParams, TraceRequestEndParams + + from collections.abc import Set from types import SimpleNamespace from typing import Any from typing import Optional @@ -64,14 +71,20 @@ class AioHttpIntegration(Integration): identifier = "aiohttp" origin = f"auto.http.{identifier}" - def __init__(self, transaction_style="handler_name"): - # type: (str) -> None + def __init__( + self, + transaction_style="handler_name", # type: str + *, + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self._failed_request_status_codes = failed_request_status_codes @staticmethod def setup_once(): @@ -99,13 +112,14 @@ def setup_once(): async def sentry_app_handle(self, request, *args, **kwargs): # type: (Any, Request, *Any, **Any) -> Any - if sentry_sdk.get_client().get_integration(AioHttpIntegration) is None: + integration = sentry_sdk.get_client().get_integration(AioHttpIntegration) + if integration is None: return await old_handle(self, request, *args, **kwargs) weak_request = weakref.ref(request) with sentry_sdk.isolation_scope() as scope: - with auto_session_tracking_scope(scope, session_mode="request"): + with track_session(scope, session_mode="request"): # Scope data will not leak between requests because aiohttp # create a task to wrap each request. scope.generate_propagation_context() @@ -130,6 +144,13 @@ async def sentry_app_handle(self, request, *args, **kwargs): response = await old_handle(self, request) except HTTPException as e: transaction.set_http_status(e.status_code) + + if ( + e.status_code + in integration._failed_request_status_codes + ): + _capture_exception() + raise except (asyncio.CancelledError, ConnectionResetError): transaction.set_status(SPANSTATUS.CANCELLED) @@ -139,18 +160,31 @@ async def sentry_app_handle(self, request, *args, **kwargs): # have no way to tell. Do not set span status. reraise(*_capture_exception()) - transaction.set_http_status(response.status) + try: + # A valid response handler will return a valid response with a status. But, if the handler + # returns an invalid response (e.g. None), the line below will raise an AttributeError. + # Even though this is likely invalid, we need to handle this case to ensure we don't break + # the application. + response_status = response.status + except AttributeError: + pass + else: + transaction.set_http_status(response_status) + return response Application._handle = sentry_app_handle old_urldispatcher_resolve = UrlDispatcher.resolve + @wraps(old_urldispatcher_resolve) async def sentry_urldispatcher_resolve(self, request): # type: (UrlDispatcher, Request) -> UrlMappingMatchInfo rv = await old_urldispatcher_resolve(self, request) integration = sentry_sdk.get_client().get_integration(AioHttpIntegration) + if integration is None: + return rv name = None @@ -205,7 +239,7 @@ async def on_request_start(session, trace_config_ctx, params): span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), origin=AioHttpIntegration.origin, ) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 41d8e9d7d5..87e69a3113 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -1,4 +1,5 @@ from functools import wraps +from typing import TYPE_CHECKING import sentry_sdk from sentry_sdk.ai.monitoring import record_token_usage @@ -7,24 +8,20 @@ from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, - ensure_integration_enabled, event_from_exception, package_version, ) -from typing import TYPE_CHECKING - try: - from anthropic.resources import Messages + from anthropic.resources import AsyncMessages, Messages if TYPE_CHECKING: from anthropic.types import MessageStreamEvent except ImportError: raise DidNotEnable("Anthropic not installed") - if TYPE_CHECKING: - from typing import Any, Iterator + from typing import Any, AsyncIterator, Iterator from sentry_sdk.tracing import Span @@ -48,6 +45,7 @@ def setup_once(): raise DidNotEnable("anthropic 0.16 or newer required.") Messages.create = _wrap_message_create(Messages.create) + AsyncMessages.create = _wrap_message_create_async(AsyncMessages.create) def _capture_exception(exc): @@ -75,105 +73,219 @@ def _calculate_token_usage(result, span): record_token_usage(span, input_tokens, output_tokens, total_tokens) +def _get_responses(content): + # type: (list[Any]) -> list[dict[str, Any]] + """ + Get JSON of a Anthropic responses. + """ + responses = [] + for item in content: + if hasattr(item, "text"): + responses.append( + { + "type": item.type, + "text": item.text, + } + ) + return responses + + +def _collect_ai_data(event, input_tokens, output_tokens, content_blocks): + # type: (MessageStreamEvent, int, int, list[str]) -> tuple[int, int, list[str]] + """ + Count token usage and collect content blocks from the AI streaming response. + """ + with capture_internal_exceptions(): + if hasattr(event, "type"): + if event.type == "message_start": + usage = event.message.usage + input_tokens += usage.input_tokens + output_tokens += usage.output_tokens + elif event.type == "content_block_start": + pass + elif event.type == "content_block_delta": + if hasattr(event.delta, "text"): + content_blocks.append(event.delta.text) + elif event.type == "content_block_stop": + pass + elif event.type == "message_delta": + output_tokens += event.usage.output_tokens + + return input_tokens, output_tokens, content_blocks + + +def _add_ai_data_to_span( + span, integration, input_tokens, output_tokens, content_blocks +): + # type: (Span, AnthropicIntegration, int, int, list[str]) -> None + """ + Add token usage and content blocks from the AI streaming response to the span. + """ + with capture_internal_exceptions(): + if should_send_default_pii() and integration.include_prompts: + complete_message = "".join(content_blocks) + span.set_data( + SPANDATA.AI_RESPONSES, + [{"type": "text", "text": complete_message}], + ) + total_tokens = input_tokens + output_tokens + record_token_usage(span, input_tokens, output_tokens, total_tokens) + span.set_data(SPANDATA.AI_STREAMING, True) + + +def _sentry_patched_create_common(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + integration = kwargs.pop("integration") + if integration is None: + return f(*args, **kwargs) + + if "messages" not in kwargs: + return f(*args, **kwargs) + + try: + iter(kwargs["messages"]) + except TypeError: + return f(*args, **kwargs) + + span = sentry_sdk.start_span( + op=OP.ANTHROPIC_MESSAGES_CREATE, + description="Anthropic messages create", + origin=AnthropicIntegration.origin, + ) + span.__enter__() + + result = yield f, args, kwargs + + # add data to span and finish it + messages = list(kwargs["messages"]) + model = kwargs.get("model") + + with capture_internal_exceptions(): + span.set_data(SPANDATA.AI_MODEL_ID, model) + span.set_data(SPANDATA.AI_STREAMING, False) + + if should_send_default_pii() and integration.include_prompts: + span.set_data(SPANDATA.AI_INPUT_MESSAGES, messages) + + if hasattr(result, "content"): + if should_send_default_pii() and integration.include_prompts: + span.set_data(SPANDATA.AI_RESPONSES, _get_responses(result.content)) + _calculate_token_usage(result, span) + span.__exit__(None, None, None) + + # Streaming response + elif hasattr(result, "_iterator"): + old_iterator = result._iterator + + def new_iterator(): + # type: () -> Iterator[MessageStreamEvent] + input_tokens = 0 + output_tokens = 0 + content_blocks = [] # type: list[str] + + for event in old_iterator: + input_tokens, output_tokens, content_blocks = _collect_ai_data( + event, input_tokens, output_tokens, content_blocks + ) + if event.type != "message_stop": + yield event + + _add_ai_data_to_span( + span, integration, input_tokens, output_tokens, content_blocks + ) + span.__exit__(None, None, None) + + async def new_iterator_async(): + # type: () -> AsyncIterator[MessageStreamEvent] + input_tokens = 0 + output_tokens = 0 + content_blocks = [] # type: list[str] + + async for event in old_iterator: + input_tokens, output_tokens, content_blocks = _collect_ai_data( + event, input_tokens, output_tokens, content_blocks + ) + if event.type != "message_stop": + yield event + + _add_ai_data_to_span( + span, integration, input_tokens, output_tokens, content_blocks + ) + span.__exit__(None, None, None) + + if str(type(result._iterator)) == "": + result._iterator = new_iterator_async() + else: + result._iterator = new_iterator() + + else: + span.set_data("unknown_response", True) + span.__exit__(None, None, None) + + return result + + def _wrap_message_create(f): # type: (Any) -> Any + def _execute_sync(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _sentry_patched_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as exc: + _capture_exception(exc) + raise exc from None + + return gen.send(result) + except StopIteration as e: + return e.value + @wraps(f) - @ensure_integration_enabled(AnthropicIntegration, f) - def _sentry_patched_create(*args, **kwargs): + def _sentry_patched_create_sync(*args, **kwargs): # type: (*Any, **Any) -> Any - if "messages" not in kwargs: - return f(*args, **kwargs) + integration = sentry_sdk.get_client().get_integration(AnthropicIntegration) + kwargs["integration"] = integration - try: - iter(kwargs["messages"]) - except TypeError: - return f(*args, **kwargs) + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync - messages = list(kwargs["messages"]) - model = kwargs.get("model") - span = sentry_sdk.start_span( - op=OP.ANTHROPIC_MESSAGES_CREATE, - description="Anthropic messages create", - origin=AnthropicIntegration.origin, - ) - span.__enter__() +def _wrap_message_create_async(f): + # type: (Any) -> Any + async def _execute_async(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _sentry_patched_create_common(f, *args, **kwargs) try: - result = f(*args, **kwargs) - except Exception as exc: - _capture_exception(exc) - span.__exit__(None, None, None) - raise exc from None + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value - integration = sentry_sdk.get_client().get_integration(AnthropicIntegration) + try: + try: + result = await f(*args, **kwargs) + except Exception as exc: + _capture_exception(exc) + raise exc from None - with capture_internal_exceptions(): - span.set_data(SPANDATA.AI_MODEL_ID, model) - span.set_data(SPANDATA.AI_STREAMING, False) - if should_send_default_pii() and integration.include_prompts: - span.set_data(SPANDATA.AI_INPUT_MESSAGES, messages) - if hasattr(result, "content"): - if should_send_default_pii() and integration.include_prompts: - span.set_data( - SPANDATA.AI_RESPONSES, - list( - map( - lambda message: { - "type": message.type, - "text": message.text, - }, - result.content, - ) - ), - ) - _calculate_token_usage(result, span) - span.__exit__(None, None, None) - elif hasattr(result, "_iterator"): - old_iterator = result._iterator - - def new_iterator(): - # type: () -> Iterator[MessageStreamEvent] - input_tokens = 0 - output_tokens = 0 - content_blocks = [] - with capture_internal_exceptions(): - for event in old_iterator: - if hasattr(event, "type"): - if event.type == "message_start": - usage = event.message.usage - input_tokens += usage.input_tokens - output_tokens += usage.output_tokens - elif event.type == "content_block_start": - pass - elif event.type == "content_block_delta": - content_blocks.append(event.delta.text) - elif event.type == "content_block_stop": - pass - elif event.type == "message_delta": - output_tokens += event.usage.output_tokens - elif event.type == "message_stop": - continue - yield event - - if should_send_default_pii() and integration.include_prompts: - complete_message = "".join(content_blocks) - span.set_data( - SPANDATA.AI_RESPONSES, - [{"type": "text", "text": complete_message}], - ) - total_tokens = input_tokens + output_tokens - record_token_usage( - span, input_tokens, output_tokens, total_tokens - ) - span.set_data(SPANDATA.AI_STREAMING, True) - span.__exit__(None, None, None) + return gen.send(result) + except StopIteration as e: + return e.value - result._iterator = new_iterator() - else: - span.set_data("unknown_response", True) - span.__exit__(None, None, None) + @wraps(f) + async def _sentry_patched_create_async(*args, **kwargs): + # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(AnthropicIntegration) + kwargs["integration"] = integration - return result + return await _execute_async(f, *args, **kwargs) - return _sentry_patched_create + return _sentry_patched_create_async diff --git a/sentry_sdk/integrations/argv.py b/sentry_sdk/integrations/argv.py index 3154f0c431..315feefb4a 100644 --- a/sentry_sdk/integrations/argv.py +++ b/sentry_sdk/integrations/argv.py @@ -4,7 +4,7 @@ from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/integrations/ariadne.py b/sentry_sdk/integrations/ariadne.py index c58caec8f0..70a3424a48 100644 --- a/sentry_sdk/integrations/ariadne.py +++ b/sentry_sdk/integrations/ariadne.py @@ -12,7 +12,6 @@ event_from_exception, package_version, ) -from sentry_sdk._types import TYPE_CHECKING try: # importing like this is necessary due to name shadowing in ariadne @@ -21,6 +20,7 @@ except ImportError: raise DidNotEnable("ariadne is not installed") +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Dict, List, Optional diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index c347ec5138..d568714fe2 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -1,7 +1,6 @@ import sys import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP, SPANSTATUS from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.logging import ignore_logger @@ -24,6 +23,8 @@ except ImportError: raise DidNotEnable("Arq is not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Dict, Optional, Union @@ -78,7 +79,7 @@ async def _sentry_enqueue_job(self, function, *args, **kwargs): return await old_enqueue_job(self, function, *args, **kwargs) with sentry_sdk.start_span( - op=OP.QUEUE_SUBMIT_ARQ, description=function, origin=ArqIntegration.origin + op=OP.QUEUE_SUBMIT_ARQ, name=function, origin=ArqIntegration.origin ): return await old_enqueue_job(self, function, *args, **kwargs) @@ -197,6 +198,17 @@ def _sentry_create_worker(*args, **kwargs): # type: (*Any, **Any) -> Worker settings_cls = args[0] + if isinstance(settings_cls, dict): + if "functions" in settings_cls: + settings_cls["functions"] = [ + _get_arq_function(func) for func in settings_cls["functions"] + ] + if "cron_jobs" in settings_cls: + settings_cls["cron_jobs"] = [ + _get_arq_cron_job(cron_job) + for cron_job in settings_cls["cron_jobs"] + ] + if hasattr(settings_cls, "functions"): settings_cls.functions = [ _get_arq_function(func) for func in settings_cls.functions diff --git a/sentry_sdk/integrations/asgi.py b/sentry_sdk/integrations/asgi.py index c0553cb474..f5e8665b4f 100644 --- a/sentry_sdk/integrations/asgi.py +++ b/sentry_sdk/integrations/asgi.py @@ -10,7 +10,6 @@ from functools import partial import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP @@ -19,12 +18,17 @@ _get_request_data, _get_url, ) -from sentry_sdk.sessions import auto_session_tracking_scope +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + nullcontext, +) +from sentry_sdk.sessions import track_session from sentry_sdk.tracing import ( SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE, TRANSACTION_SOURCE_URL, TRANSACTION_SOURCE_COMPONENT, + TRANSACTION_SOURCE_CUSTOM, ) from sentry_sdk.utils import ( ContextVar, @@ -37,6 +41,8 @@ ) from sentry_sdk.tracing import Transaction +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -88,17 +94,19 @@ class SentryAsgiMiddleware: "transaction_style", "mechanism_type", "span_origin", + "http_methods_to_capture", ) def __init__( self, - app, - unsafe_context_data=False, - transaction_style="endpoint", - mechanism_type="asgi", - span_origin="manual", + app, # type: Any + unsafe_context_data=False, # type: bool + transaction_style="endpoint", # type: str + mechanism_type="asgi", # type: str + span_origin="manual", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: Tuple[str, ...] ): - # type: (Any, bool, str, str, str) -> None + # type: (...) -> None """ Instrument an ASGI application with Sentry. Provides HTTP/websocket data to sent events and basic handling for exceptions bubbling up @@ -133,6 +141,7 @@ def __init__( self.mechanism_type = mechanism_type self.span_origin = span_origin self.app = app + self.http_methods_to_capture = http_methods_to_capture if _looks_like_asgi3(app): self.__call__ = self._run_asgi3 # type: Callable[..., Any] @@ -169,7 +178,7 @@ async def _run_app(self, scope, receive, send, asgi_version): _asgi_middleware_applied.set(True) try: with sentry_sdk.isolation_scope() as sentry_scope: - with auto_session_tracking_scope(sentry_scope, session_mode="request"): + with track_session(sentry_scope, session_mode="request"): sentry_scope.clear_breadcrumbs() sentry_scope._name = "asgi" processor = partial(self.event_processor, asgi_scope=scope) @@ -184,52 +193,59 @@ async def _run_app(self, scope, receive, send, asgi_version): scope, ) - if ty in ("http", "websocket"): - transaction = continue_trace( - _get_headers(scope), - op="{}.server".format(ty), - name=transaction_name, - source=transaction_source, - origin=self.span_origin, - ) - logger.debug( - "[ASGI] Created transaction (continuing trace): %s", - transaction, - ) - else: - transaction = Transaction( - op=OP.HTTP_SERVER, - name=transaction_name, - source=transaction_source, - origin=self.span_origin, - ) + method = scope.get("method", "").upper() + transaction = None + if method in self.http_methods_to_capture: + if ty in ("http", "websocket"): + transaction = continue_trace( + _get_headers(scope), + op="{}.server".format(ty), + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) + logger.debug( + "[ASGI] Created transaction (continuing trace): %s", + transaction, + ) + else: + transaction = Transaction( + op=OP.HTTP_SERVER, + name=transaction_name, + source=transaction_source, + origin=self.span_origin, + ) + logger.debug( + "[ASGI] Created transaction (new): %s", transaction + ) + + transaction.set_tag("asgi.type", ty) logger.debug( - "[ASGI] Created transaction (new): %s", transaction + "[ASGI] Set transaction name and source on transaction: '%s' / '%s'", + transaction.name, + transaction.source, ) - transaction.set_tag("asgi.type", ty) - logger.debug( - "[ASGI] Set transaction name and source on transaction: '%s' / '%s'", - transaction.name, - transaction.source, - ) - - with sentry_sdk.start_transaction( - transaction, - custom_sampling_context={"asgi_scope": scope}, + with ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"asgi_scope": scope}, + ) + if transaction is not None + else nullcontext() ): logger.debug("[ASGI] Started transaction: %s", transaction) try: async def _sentry_wrapped_send(event): # type: (Dict[str, Any]) -> Any - is_http_response = ( - event.get("type") == "http.response.start" - and transaction is not None - and "status" in event - ) - if is_http_response: - transaction.set_http_status(event["status"]) + if transaction is not None: + is_http_response = ( + event.get("type") == "http.response.start" + and "status" in event + ) + if is_http_response: + transaction.set_http_status(event["status"]) return await send(event) @@ -259,6 +275,7 @@ def event_processor(self, event, hint, asgi_scope): ].get("source") in [ TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_ROUTE, + TRANSACTION_SOURCE_CUSTOM, ] if not already_set: name, source = self._get_transaction_name_and_source( diff --git a/sentry_sdk/integrations/asyncio.py b/sentry_sdk/integrations/asyncio.py index 8a62755caa..7021d7fceb 100644 --- a/sentry_sdk/integrations/asyncio.py +++ b/sentry_sdk/integrations/asyncio.py @@ -3,7 +3,6 @@ import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations import Integration, DidNotEnable -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import event_from_exception, reraise try: @@ -12,6 +11,7 @@ except ImportError: raise DidNotEnable("asyncio not available") +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -46,7 +46,7 @@ async def _coro_creating_hub_and_span(): with sentry_sdk.isolation_scope(): with sentry_sdk.start_span( op=OP.FUNCTION, - description=get_name(coro), + name=get_name(coro), origin=AsyncioIntegration.origin, ): try: diff --git a/sentry_sdk/integrations/asyncpg.py b/sentry_sdk/integrations/asyncpg.py index 4c1611613b..b05d5615ba 100644 --- a/sentry_sdk/integrations/asyncpg.py +++ b/sentry_sdk/integrations/asyncpg.py @@ -165,7 +165,7 @@ async def _inner(*args: Any, **kwargs: Any) -> T: with sentry_sdk.start_span( op=OP.DB, - description="connect", + name="connect", origin=AsyncPGIntegration.origin, ) as span: span.set_data(SPANDATA.DB_SYSTEM, "postgresql") diff --git a/sentry_sdk/integrations/atexit.py b/sentry_sdk/integrations/atexit.py index 9babbf235d..dfc6d08e1a 100644 --- a/sentry_sdk/integrations/atexit.py +++ b/sentry_sdk/integrations/atexit.py @@ -5,8 +5,7 @@ import sentry_sdk from sentry_sdk.utils import logger from sentry_sdk.integrations import Integration -from sentry_sdk.utils import ensure_integration_enabled -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -43,13 +42,16 @@ def __init__(self, callback=None): def setup_once(): # type: () -> None @atexit.register - @ensure_integration_enabled(AtexitIntegration) def _shutdown(): # type: () -> None - logger.debug("atexit: got shutdown signal") client = sentry_sdk.get_client() integration = client.get_integration(AtexitIntegration) + if integration is None: + return + + logger.debug("atexit: got shutdown signal") logger.debug("atexit: shutting down client") sentry_sdk.get_isolation_scope().end_session() + client.close(callback=integration.callback) diff --git a/sentry_sdk/integrations/aws_lambda.py b/sentry_sdk/integrations/aws_lambda.py index 560511b48b..831cde8999 100644 --- a/sentry_sdk/integrations/aws_lambda.py +++ b/sentry_sdk/integrations/aws_lambda.py @@ -1,3 +1,6 @@ +import functools +import json +import re import sys from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -19,7 +22,8 @@ ) from sentry_sdk.integrations import Integration from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -55,6 +59,11 @@ def sentry_init_error(*args, **kwargs): ) sentry_sdk.capture_event(sentry_event, hint=hint) + else: + # Fall back to AWS lambdas JSON representation of the error + sentry_event = _event_from_error_json(json.loads(args[1])) + sentry_sdk.capture_event(sentry_event) + return init_error(*args, **kwargs) return sentry_init_error # type: ignore @@ -62,7 +71,7 @@ def sentry_init_error(*args, **kwargs): def _wrap_handler(handler): # type: (F) -> F - @ensure_integration_enabled(AwsLambdaIntegration, handler) + @functools.wraps(handler) def sentry_handler(aws_event, aws_context, *args, **kwargs): # type: (Any, Any, *Any, **Any) -> Any @@ -76,6 +85,12 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): # will be the same for all events in the list, since they're all hitting # the lambda in the same request.) + client = sentry_sdk.get_client() + integration = client.get_integration(AwsLambdaIntegration) + + if integration is None: + return handler(aws_event, aws_context, *args, **kwargs) + if isinstance(aws_event, list) and len(aws_event) >= 1: request_data = aws_event[0] batch_size = len(aws_event) @@ -89,9 +104,6 @@ def sentry_handler(aws_event, aws_context, *args, **kwargs): # this is empty request_data = {} - client = sentry_sdk.get_client() - integration = client.get_integration(AwsLambdaIntegration) - configured_time = aws_context.get_remaining_time_in_millis() with sentry_sdk.isolation_scope() as scope: @@ -427,3 +439,58 @@ def _get_cloudwatch_logs_url(aws_context, start_time): ) return url + + +def _parse_formatted_traceback(formatted_tb): + # type: (list[str]) -> list[dict[str, Any]] + frames = [] + for frame in formatted_tb: + match = re.match(r'File "(.+)", line (\d+), in (.+)', frame.strip()) + if match: + file_name, line_number, func_name = match.groups() + line_number = int(line_number) + frames.append( + { + "filename": file_name, + "function": func_name, + "lineno": line_number, + "vars": None, + "pre_context": None, + "context_line": None, + "post_context": None, + } + ) + return frames + + +def _event_from_error_json(error_json): + # type: (dict[str, Any]) -> Event + """ + Converts the error JSON from AWS Lambda into a Sentry error event. + This is not a full fletched event, but better than nothing. + + This is an example of where AWS creates the error JSON: + https://github.com/aws/aws-lambda-python-runtime-interface-client/blob/2.2.1/awslambdaric/bootstrap.py#L479 + """ + event = { + "level": "error", + "exception": { + "values": [ + { + "type": error_json.get("errorType"), + "value": error_json.get("errorMessage"), + "stacktrace": { + "frames": _parse_formatted_traceback( + error_json.get("stackTrace", []) + ), + }, + "mechanism": { + "type": "aws_lambda", + "handled": False, + }, + } + ], + }, + } # type: Event + + return event diff --git a/sentry_sdk/integrations/beam.py b/sentry_sdk/integrations/beam.py index a2323cb406..a2e4553f5a 100644 --- a/sentry_sdk/integrations/beam.py +++ b/sentry_sdk/integrations/beam.py @@ -11,7 +11,8 @@ event_from_exception, reraise, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/boto3.py b/sentry_sdk/integrations/boto3.py index 0fb997767b..c8da56fb14 100644 --- a/sentry_sdk/integrations/boto3.py +++ b/sentry_sdk/integrations/boto3.py @@ -4,8 +4,6 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import Span - -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( capture_internal_exceptions, ensure_integration_enabled, @@ -13,6 +11,8 @@ parse_version, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Dict @@ -69,7 +69,7 @@ def _sentry_request_created(service_id, request, operation_name, **kwargs): description = "aws.%s.%s" % (service_id, operation_name) span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description=description, + name=description, origin=Boto3Integration.origin, ) @@ -107,7 +107,7 @@ def _sentry_after_call(context, parsed, **kwargs): streaming_span = span.start_child( op=OP.HTTP_CLIENT_STREAM, - description=span.description, + name=span.description, origin=Boto3Integration.origin, ) diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index c5dca2f822..a2d6b51033 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -1,3 +1,5 @@ +import functools + import sentry_sdk from sentry_sdk.tracing import SOURCE_FOR_STYLE from sentry_sdk.utils import ( @@ -7,12 +9,19 @@ parse_version, transaction_from_function, ) -from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations import ( + Integration, + DidNotEnable, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.integrations._wsgi_common import RequestExtractor -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Set + from sentry_sdk.integrations.wsgi import _ScopedResponse from typing import Any from typing import Dict @@ -25,9 +34,9 @@ try: from bottle import ( Bottle, + HTTPResponse, Route, request as bottle_request, - HTTPResponse, __version__ as BOTTLE_VERSION, ) except ImportError: @@ -43,8 +52,13 @@ class BottleIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + *, + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Set[int] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( @@ -52,6 +66,7 @@ def __init__(self, transaction_style="endpoint"): % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.failed_request_status_codes = failed_request_status_codes @staticmethod def setup_once(): @@ -80,10 +95,12 @@ def sentry_patched_wsgi_app(self, environ, start_response): old_handle = Bottle._handle - @ensure_integration_enabled(BottleIntegration, old_handle) + @functools.wraps(old_handle) def _patched_handle(self, environ): # type: (Bottle, Dict[str, Any]) -> Any integration = sentry_sdk.get_client().get_integration(BottleIntegration) + if integration is None: + return old_handle(self, environ) scope = sentry_sdk.get_isolation_scope() scope._name = "bottle" @@ -98,28 +115,29 @@ def _patched_handle(self, environ): old_make_callback = Route._make_callback - @ensure_integration_enabled(BottleIntegration, old_make_callback) + @functools.wraps(old_make_callback) def patched_make_callback(self, *args, **kwargs): # type: (Route, *object, **object) -> Any - client = sentry_sdk.get_client() prepared_callback = old_make_callback(self, *args, **kwargs) + integration = sentry_sdk.get_client().get_integration(BottleIntegration) + if integration is None: + return prepared_callback + def wrapped_callback(*args, **kwargs): # type: (*object, **object) -> Any - try: res = prepared_callback(*args, **kwargs) - except HTTPResponse: - raise except Exception as exception: - event, hint = event_from_exception( - exception, - client_options=client.options, - mechanism={"type": "bottle", "handled": False}, - ) - sentry_sdk.capture_event(event, hint=hint) + _capture_exception(exception, handled=False) raise exception + if ( + isinstance(res, HTTPResponse) + and res.status_code in integration.failed_request_status_codes + ): + _capture_exception(res, handled=True) + return res return wrapped_callback @@ -189,3 +207,13 @@ def event_processor(event, hint): return event return event_processor + + +def _capture_exception(exception, handled): + # type: (BaseException, bool) -> None + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={"type": "bottle", "handled": handled}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index e1b54d0a37..9a984de8c3 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -15,7 +15,6 @@ from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.tracing import BAGGAGE_HEADER_NAME, TRANSACTION_SOURCE_TASK -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.tracing_utils import Baggage from sentry_sdk.utils import ( capture_internal_exceptions, @@ -24,6 +23,8 @@ reraise, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -40,6 +41,7 @@ try: from celery import VERSION as CELERY_VERSION # type: ignore + from celery.app.task import Task # type: ignore from celery.app.trace import task_has_custom from celery.exceptions import ( # type: ignore Ignore, @@ -82,6 +84,7 @@ def setup_once(): _patch_build_tracer() _patch_task_apply_async() + _patch_celery_send_task() _patch_worker_exit() _patch_producer_publish() @@ -242,16 +245,18 @@ def __exit__(self, exc_type, exc_value, traceback): return None -def _wrap_apply_async(f): +def _wrap_task_run(f): # type: (F) -> F @wraps(f) - @ensure_integration_enabled(CeleryIntegration, f) def apply_async(*args, **kwargs): # type: (*Any, **Any) -> Any # Note: kwargs can contain headers=None, so no setdefault! # Unsure which backend though. - kwarg_headers = kwargs.get("headers") or {} integration = sentry_sdk.get_client().get_integration(CeleryIntegration) + if integration is None: + return f(*args, **kwargs) + + kwarg_headers = kwargs.get("headers") or {} propagate_traces = kwarg_headers.pop( "sentry-propagate-traces", integration.propagate_traces ) @@ -259,14 +264,19 @@ def apply_async(*args, **kwargs): if not propagate_traces: return f(*args, **kwargs) - task = args[0] + if isinstance(args[0], Task): + task_name = args[0].name # type: str + elif len(args) > 1 and isinstance(args[1], str): + task_name = args[1] + else: + task_name = "" task_started_from_beat = sentry_sdk.get_isolation_scope()._name == "celery-beat" span_mgr = ( sentry_sdk.start_span( op=OP.QUEUE_SUBMIT_CELERY, - description=task.name, + name=task_name, origin=CeleryIntegration.origin, ) if not task_started_from_beat @@ -366,7 +376,7 @@ def _inner(*args, **kwargs): try: with sentry_sdk.start_span( op=OP.QUEUE_PROCESS, - description=task.name, + name=task.name, origin=CeleryIntegration.origin, ) as span: _set_messaging_destination_name(task, span) @@ -436,9 +446,14 @@ def sentry_build_tracer(name, task, *args, **kwargs): def _patch_task_apply_async(): # type: () -> None - from celery.app.task import Task # type: ignore + Task.apply_async = _wrap_task_run(Task.apply_async) + + +def _patch_celery_send_task(): + # type: () -> None + from celery import Celery - Task.apply_async = _wrap_apply_async(Task.apply_async) + Celery.send_task = _wrap_task_run(Celery.send_task) def _patch_worker_exit(): @@ -490,7 +505,7 @@ def sentry_publish(self, *args, **kwargs): with sentry_sdk.start_span( op=OP.QUEUE_PUBLISH, - description=task_name, + name=task_name, origin=CeleryIntegration.origin, ) as span: if task_id is not None: diff --git a/sentry_sdk/integrations/celery/beat.py b/sentry_sdk/integrations/celery/beat.py index b40c39fa80..ddbc8561a4 100644 --- a/sentry_sdk/integrations/celery/beat.py +++ b/sentry_sdk/integrations/celery/beat.py @@ -5,12 +5,13 @@ _get_humanized_interval, _now_seconds_since_epoch, ) -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( logger, match_regex_list, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Callable from typing import Any, Optional, TypeVar, Union diff --git a/sentry_sdk/integrations/celery/utils.py b/sentry_sdk/integrations/celery/utils.py index 952911a9f6..a1961b15bc 100644 --- a/sentry_sdk/integrations/celery/utils.py +++ b/sentry_sdk/integrations/celery/utils.py @@ -1,7 +1,5 @@ import time -from typing import cast - -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: from typing import Any, Tuple diff --git a/sentry_sdk/integrations/chalice.py b/sentry_sdk/integrations/chalice.py index 379e46883f..0754d1f13b 100644 --- a/sentry_sdk/integrations/chalice.py +++ b/sentry_sdk/integrations/chalice.py @@ -11,7 +11,6 @@ parse_version, reraise, ) -from sentry_sdk._types import TYPE_CHECKING try: import chalice # type: ignore @@ -21,6 +20,8 @@ except ImportError: raise DidNotEnable("Chalice is not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Dict diff --git a/sentry_sdk/integrations/clickhouse_driver.py b/sentry_sdk/integrations/clickhouse_driver.py index 0f63f868d5..daf4c2257c 100644 --- a/sentry_sdk/integrations/clickhouse_driver.py +++ b/sentry_sdk/integrations/clickhouse_driver.py @@ -2,11 +2,10 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import Span -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar # Hack to get new Python features working in older versions # without introducing a hard dependency on `typing_extensions` @@ -84,7 +83,7 @@ def _inner(*args: P.args, **kwargs: P.kwargs) -> T: span = sentry_sdk.start_span( op=OP.DB, - description=query, + name=query, origin=ClickhouseDriverIntegration.origin, ) diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py index 695bf17d38..8d080899f3 100644 --- a/sentry_sdk/integrations/cloud_resource_context.py +++ b/sentry_sdk/integrations/cloud_resource_context.py @@ -5,7 +5,7 @@ from sentry_sdk.api import set_context from sentry_sdk.utils import logger -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Dict diff --git a/sentry_sdk/integrations/cohere.py b/sentry_sdk/integrations/cohere.py index b32d720b77..b4c2af91da 100644 --- a/sentry_sdk/integrations/cohere.py +++ b/sentry_sdk/integrations/cohere.py @@ -1,11 +1,12 @@ from functools import wraps from sentry_sdk import consts -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.ai.monitoring import record_token_usage from sentry_sdk.consts import SPANDATA from sentry_sdk.ai.utils import set_data_normalized +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Iterator from sentry_sdk.tracing import Span @@ -13,11 +14,7 @@ import sentry_sdk from sentry_sdk.scope import should_send_default_pii from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.utils import ( - capture_internal_exceptions, - event_from_exception, - ensure_integration_enabled, -) +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception try: from cohere.client import Client @@ -25,7 +22,6 @@ from cohere import ( ChatStreamEndEvent, NonStreamedChatResponse, - StreamedChatResponse_StreamEnd, ) if TYPE_CHECKING: @@ -33,6 +29,12 @@ except ImportError: raise DidNotEnable("Cohere not installed") +try: + # cohere 5.9.3+ + from cohere import StreamEndStreamedChatResponse +except ImportError: + from cohere import StreamedChatResponse_StreamEnd as StreamEndStreamedChatResponse + COLLECTED_CHAT_PARAMS = { "model": SPANDATA.AI_MODEL_ID, @@ -128,20 +130,22 @@ def collect_chat_response_fields(span, res, include_pii): set_data_normalized(span, "ai.warnings", res.meta.warnings) @wraps(f) - @ensure_integration_enabled(CohereIntegration, f) def new_chat(*args, **kwargs): # type: (*Any, **Any) -> Any - if "message" not in kwargs: - return f(*args, **kwargs) + integration = sentry_sdk.get_client().get_integration(CohereIntegration) - if not isinstance(kwargs.get("message"), str): + if ( + integration is None + or "message" not in kwargs + or not isinstance(kwargs.get("message"), str) + ): return f(*args, **kwargs) message = kwargs.get("message") span = sentry_sdk.start_span( op=consts.OP.COHERE_CHAT_COMPLETIONS_CREATE, - description="cohere.client.Chat", + name="cohere.client.Chat", origin=CohereIntegration.origin, ) span.__enter__() @@ -152,8 +156,6 @@ def new_chat(*args, **kwargs): span.__exit__(None, None, None) raise e from None - integration = sentry_sdk.get_client().get_integration(CohereIntegration) - with capture_internal_exceptions(): if should_send_default_pii() and integration.include_prompts: set_data_normalized( @@ -188,7 +190,7 @@ def new_iterator(): with capture_internal_exceptions(): for x in old_iterator: if isinstance(x, ChatStreamEndEvent) or isinstance( - x, StreamedChatResponse_StreamEnd + x, StreamEndStreamedChatResponse ): collect_chat_response_fields( span, @@ -221,15 +223,17 @@ def _wrap_embed(f): # type: (Callable[..., Any]) -> Callable[..., Any] @wraps(f) - @ensure_integration_enabled(CohereIntegration, f) def new_embed(*args, **kwargs): # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(CohereIntegration) + if integration is None: + return f(*args, **kwargs) + with sentry_sdk.start_span( op=consts.OP.COHERE_EMBEDDINGS_CREATE, - description="Cohere Embedding Creation", + name="Cohere Embedding Creation", origin=CohereIntegration.origin, ) as span: - integration = sentry_sdk.get_client().get_integration(CohereIntegration) if "texts" in kwargs and ( should_send_default_pii() and integration.include_prompts ): diff --git a/sentry_sdk/integrations/dedupe.py b/sentry_sdk/integrations/dedupe.py index 02469b6911..be6d9311a3 100644 --- a/sentry_sdk/integrations/dedupe.py +++ b/sentry_sdk/integrations/dedupe.py @@ -3,7 +3,7 @@ from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/integrations/django/__init__.py b/sentry_sdk/integrations/django/__init__.py index 508df2e431..e68f0cacef 100644 --- a/sentry_sdk/integrations/django/__init__.py +++ b/sentry_sdk/integrations/django/__init__.py @@ -5,9 +5,7 @@ from importlib import import_module import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.db.explain_plan.django import attach_explain_plan_to_span from sentry_sdk.scope import add_global_event_processor, should_send_default_pii from sentry_sdk.serializer import add_global_repr_processor from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_URL @@ -27,7 +25,10 @@ from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware -from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + RequestExtractor, +) try: from django import VERSION as DJANGO_VERSION @@ -68,6 +69,7 @@ else: patch_caching = None # type: ignore +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -126,13 +128,14 @@ class DjangoIntegration(Integration): def __init__( self, - transaction_style="url", - middleware_spans=True, - signals_spans=True, - cache_spans=False, - signals_denylist=None, + transaction_style="url", # type: str + middleware_spans=True, # type: bool + signals_spans=True, # type: bool + cache_spans=False, # type: bool + signals_denylist=None, # type: Optional[list[signals.Signal]] + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...] ): - # type: (str, bool, bool, bool, Optional[list[signals.Signal]]) -> None + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -146,6 +149,8 @@ def __init__( self.cache_spans = cache_spans + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) + @staticmethod def setup_once(): # type: () -> None @@ -173,10 +178,17 @@ def sentry_patched_wsgi_handler(self, environ, start_response): use_x_forwarded_for = settings.USE_X_FORWARDED_HOST + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + middleware = SentryWsgiMiddleware( bound_old_app, use_x_forwarded_for, span_origin=DjangoIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) return middleware(environ, start_response) @@ -412,10 +424,11 @@ def _set_transaction_name_and_source(scope, transaction_style, request): pass -@ensure_integration_enabled(DjangoIntegration) def _before_get_response(request): # type: (WSGIRequest) -> None integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: + return _patch_drf() @@ -441,11 +454,10 @@ def _attempt_resolve_again(request, scope, transaction_style): _set_transaction_name_and_source(scope, transaction_style, request) -@ensure_integration_enabled(DjangoIntegration) def _after_get_response(request): # type: (WSGIRequest) -> None integration = sentry_sdk.get_client().get_integration(DjangoIntegration) - if integration.transaction_style != "url": + if integration is None or integration.transaction_style != "url": return scope = sentry_sdk.get_current_scope() @@ -492,13 +504,6 @@ def wsgi_request_event_processor(event, hint): # We have a `asgi_request_event_processor` for this. return event - try: - drf_request = request._sentry_drf_request_backref() - if drf_request is not None: - request = drf_request - except AttributeError: - pass - with capture_internal_exceptions(): DjangoRequestExtractor(request).extract_into_event(event) @@ -511,11 +516,12 @@ def wsgi_request_event_processor(event, hint): return wsgi_request_event_processor -@ensure_integration_enabled(DjangoIntegration) def _got_request_exception(request=None, **kwargs): # type: (WSGIRequest, **Any) -> None client = sentry_sdk.get_client() integration = client.get_integration(DjangoIntegration) + if integration is None: + return if request is not None and integration.transaction_style == "url": scope = sentry_sdk.get_current_scope() @@ -530,6 +536,16 @@ def _got_request_exception(request=None, **kwargs): class DjangoRequestExtractor(RequestExtractor): + def __init__(self, request): + # type: (Union[WSGIRequest, ASGIRequest]) -> None + try: + drf_request = request._sentry_drf_request_backref() + if drf_request is not None: + request = drf_request + except AttributeError: + pass + self.request = request + def env(self): # type: () -> Dict[str, str] return self.request.META @@ -634,20 +650,6 @@ def execute(self, sql, params=None): span_origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) - options = ( - sentry_sdk.get_client() - .options["_experiments"] - .get("attach_explain_plans") - ) - if options is not None: - attach_explain_plan_to_span( - span, - self.cursor.connection, - sql, - params, - self.mogrify, - options, - ) result = real_execute(self, sql, params) with capture_internal_exceptions(): @@ -683,7 +685,7 @@ def connect(self): with sentry_sdk.start_span( op=OP.DB, - description="connect", + name="connect", origin=DjangoIntegration.origin_db, ) as span: _set_db_data(span, self) @@ -715,8 +717,18 @@ def _set_db_data(span, cursor_or_db): connection_params = cursor_or_db.connection.get_dsn_parameters() else: try: - # psycopg3 - connection_params = cursor_or_db.connection.info.get_parameters() + # psycopg3, only extract needed params as get_parameters + # can be slow because of the additional logic to filter out default + # values + connection_params = { + "dbname": cursor_or_db.connection.info.dbname, + "port": cursor_or_db.connection.info.port, + } + # PGhost returns host or base dir of UNIX socket as an absolute path + # starting with /, use it only when it contains host + pg_host = cursor_or_db.connection.info.host + if pg_host and not pg_host.startswith("/"): + connection_params["host"] = pg_host except Exception: connection_params = db.get_connection_params() diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index 11691de5a4..73a25acc9f 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -13,7 +13,6 @@ from django.core.handlers.wsgi import WSGIRequest import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations.asgi import SentryAsgiMiddleware @@ -23,6 +22,7 @@ ensure_integration_enabled, ) +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Callable, Union, TypeVar @@ -90,13 +90,15 @@ def patch_django_asgi_handler_impl(cls): async def sentry_patched_asgi_handler(self, scope, receive, send): # type: (Any, Any, Any, Any) -> Any - if sentry_sdk.get_client().get_integration(DjangoIntegration) is None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: return await old_app(self, scope, receive, send) middleware = SentryAsgiMiddleware( old_app.__get__(self, cls), unsafe_context_data=True, span_origin=DjangoIntegration.origin, + http_methods_to_capture=integration.http_methods_to_capture, )._run_asgi3 return await middleware(scope, receive, send) @@ -142,13 +144,15 @@ def patch_channels_asgi_handler_impl(cls): async def sentry_patched_asgi_handler(self, receive, send): # type: (Any, Any, Any) -> Any - if sentry_sdk.get_client().get_integration(DjangoIntegration) is None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: return await old_app(self, receive, send) middleware = SentryAsgiMiddleware( lambda _scope: old_app.__get__(self, cls), unsafe_context_data=True, span_origin=DjangoIntegration.origin, + http_methods_to_capture=integration.http_methods_to_capture, ) return await middleware(self.scope)(receive, send) @@ -168,13 +172,17 @@ def wrap_async_view(callback): @functools.wraps(callback) async def sentry_wrapped_callback(request, *args, **kwargs): # type: (Any, *Any, **Any) -> Any + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + sentry_scope = sentry_sdk.get_isolation_scope() if sentry_scope.profile is not None: sentry_scope.profile.update_active_thread_id() with sentry_sdk.start_span( op=OP.VIEW_RENDER, - description=request.resolver_match.view_name, + name=request.resolver_match.view_name, origin=DjangoIntegration.origin, ): return await callback(request, *args, **kwargs) diff --git a/sentry_sdk/integrations/django/caching.py b/sentry_sdk/integrations/django/caching.py index 25b04f4820..7985611761 100644 --- a/sentry_sdk/integrations/django/caching.py +++ b/sentry_sdk/integrations/django/caching.py @@ -52,7 +52,7 @@ def _instrument_call( with sentry_sdk.start_span( op=op, - description=description, + name=description, origin=DjangoIntegration.origin, ) as span: value = original_method(*args, **kwargs) @@ -75,11 +75,12 @@ def _instrument_call( span.set_data(SPANDATA.CACHE_HIT, True) else: span.set_data(SPANDATA.CACHE_HIT, False) - else: - try: + else: # TODO: We don't handle `get_or_set` which we should + arg_count = len(args) + if arg_count >= 2: # 'set' command item_size = len(str(args[1])) - except IndexError: + elif arg_count == 1: # 'set_many' command item_size = len(str(args[0])) @@ -132,10 +133,22 @@ def _get_address_port(settings): return address, int(port) if port is not None else None -def patch_caching(): - # type: () -> None +def should_enable_cache_spans(): + # type: () -> bool from sentry_sdk.integrations.django import DjangoIntegration + client = sentry_sdk.get_client() + integration = client.get_integration(DjangoIntegration) + from django.conf import settings + + return integration is not None and ( + (client.spotlight is not None and settings.DEBUG is True) + or integration.cache_spans is True + ) + + +def patch_caching(): + # type: () -> None if not hasattr(CacheHandler, "_sentry_patched"): if DJANGO_VERSION < (3, 2): original_get_item = CacheHandler.__getitem__ @@ -145,8 +158,7 @@ def sentry_get_item(self, alias): # type: (CacheHandler, str) -> Any cache = original_get_item(self, alias) - integration = sentry_sdk.get_client().get_integration(DjangoIntegration) - if integration is not None and integration.cache_spans: + if should_enable_cache_spans(): from django.conf import settings address, port = _get_address_port( @@ -168,8 +180,7 @@ def sentry_create_connection(self, alias): # type: (CacheHandler, str) -> Any cache = original_create_connection(self, alias) - integration = sentry_sdk.get_client().get_integration(DjangoIntegration) - if integration is not None and integration.cache_spans: + if should_enable_cache_spans(): address, port = _get_address_port(self.settings[alias or "default"]) _patch_cache(cache, address, port) diff --git a/sentry_sdk/integrations/django/middleware.py b/sentry_sdk/integrations/django/middleware.py index 6f75444cbf..245276566e 100644 --- a/sentry_sdk/integrations/django/middleware.py +++ b/sentry_sdk/integrations/django/middleware.py @@ -7,7 +7,6 @@ from django import VERSION as DJANGO_VERSION import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.utils import ( ContextVar, @@ -15,6 +14,8 @@ capture_internal_exceptions, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -29,7 +30,9 @@ "import_string_should_wrap_middleware" ) -if DJANGO_VERSION < (3, 1): +DJANGO_SUPPORTS_ASYNC_MIDDLEWARE = DJANGO_VERSION >= (3, 1) + +if not DJANGO_SUPPORTS_ASYNC_MIDDLEWARE: _asgi_middleware_mixin_factory = lambda _: object else: from .asgi import _asgi_middleware_mixin_factory @@ -84,7 +87,7 @@ def _check_middleware_span(old_method): middleware_span = sentry_sdk.start_span( op=OP.MIDDLEWARE_DJANGO, - description=description, + name=description, origin=DjangoIntegration.origin, ) middleware_span.set_tag("django.function_name", function_name) @@ -122,7 +125,10 @@ def sentry_wrapped_method(*args, **kwargs): class SentryWrappingMiddleware( _asgi_middleware_mixin_factory(_check_middleware_span) # type: ignore ): - async_capable = getattr(middleware, "async_capable", False) + sync_capable = getattr(middleware, "sync_capable", True) + async_capable = DJANGO_SUPPORTS_ASYNC_MIDDLEWARE and getattr( + middleware, "async_capable", False + ) def __init__(self, get_response=None, *args, **kwargs): # type: (Optional[Callable[..., Any]], *Any, **Any) -> None diff --git a/sentry_sdk/integrations/django/signals_handlers.py b/sentry_sdk/integrations/django/signals_handlers.py index 0cd084f697..cb0f8b9d2e 100644 --- a/sentry_sdk/integrations/django/signals_handlers.py +++ b/sentry_sdk/integrations/django/signals_handlers.py @@ -3,10 +3,10 @@ from django.dispatch import Signal import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations.django import DJANGO_VERSION +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable @@ -66,7 +66,7 @@ def wrapper(*args, **kwargs): signal_name = _get_receiver_name(receiver) with sentry_sdk.start_span( op=OP.EVENT_DJANGO, - description=signal_name, + name=signal_name, origin=DjangoIntegration.origin, ) as span: span.set_data("signal", signal_name) diff --git a/sentry_sdk/integrations/django/templates.py b/sentry_sdk/integrations/django/templates.py index e91e1a908c..10e8a924b7 100644 --- a/sentry_sdk/integrations/django/templates.py +++ b/sentry_sdk/integrations/django/templates.py @@ -5,10 +5,11 @@ from django import VERSION as DJANGO_VERSION import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.utils import ensure_integration_enabled +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Dict @@ -69,7 +70,7 @@ def rendered_content(self): # type: (SimpleTemplateResponse) -> str with sentry_sdk.start_span( op=OP.TEMPLATE_RENDER, - description=_get_template_name_description(self.template_name), + name=_get_template_name_description(self.template_name), origin=DjangoIntegration.origin, ) as span: span.set_data("context", self.context_data) @@ -97,7 +98,7 @@ def render(request, template_name, context=None, *args, **kwargs): with sentry_sdk.start_span( op=OP.TEMPLATE_RENDER, - description=_get_template_name_description(template_name), + name=_get_template_name_description(template_name), origin=DjangoIntegration.origin, ) as span: span.set_data("context", context) diff --git a/sentry_sdk/integrations/django/transactions.py b/sentry_sdk/integrations/django/transactions.py index 409ae77c45..5a7d69f3c9 100644 --- a/sentry_sdk/integrations/django/transactions.py +++ b/sentry_sdk/integrations/django/transactions.py @@ -7,7 +7,7 @@ import re -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from django.urls.resolvers import URLResolver diff --git a/sentry_sdk/integrations/django/views.py b/sentry_sdk/integrations/django/views.py index 1bcee492bf..0a9861a6a6 100644 --- a/sentry_sdk/integrations/django/views.py +++ b/sentry_sdk/integrations/django/views.py @@ -2,7 +2,8 @@ import sentry_sdk from sentry_sdk.consts import OP -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -34,7 +35,7 @@ def sentry_patched_render(self): # type: (SimpleTemplateResponse) -> Any with sentry_sdk.start_span( op=OP.VIEW_RESPONSE_RENDER, - description="serialize response", + name="serialize response", origin=DjangoIntegration.origin, ): return old_render(self) @@ -75,6 +76,10 @@ def _wrap_sync_view(callback): @functools.wraps(callback) def sentry_wrapped_callback(request, *args, **kwargs): # type: (Any, *Any, **Any) -> Any + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + sentry_scope = sentry_sdk.get_isolation_scope() # set the active thread id to the handler thread for sync views # this isn't necessary for async views since that runs on main @@ -83,7 +88,7 @@ def sentry_wrapped_callback(request, *args, **kwargs): with sentry_sdk.start_span( op=OP.VIEW_RENDER, - description=request.resolver_match.view_name, + name=request.resolver_match.view_name, origin=DjangoIntegration.origin, ): return callback(request, *args, **kwargs) diff --git a/sentry_sdk/integrations/dramatiq.py b/sentry_sdk/integrations/dramatiq.py new file mode 100644 index 0000000000..f9ef13e20b --- /dev/null +++ b/sentry_sdk/integrations/dramatiq.py @@ -0,0 +1,168 @@ +import json + +import sentry_sdk +from sentry_sdk.integrations import Integration +from sentry_sdk.integrations._wsgi_common import request_body_within_bounds +from sentry_sdk.utils import ( + AnnotatedValue, + capture_internal_exceptions, + event_from_exception, +) + +from dramatiq.broker import Broker # type: ignore +from dramatiq.message import Message # type: ignore +from dramatiq.middleware import Middleware, default_middleware # type: ignore +from dramatiq.errors import Retry # type: ignore + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, Optional, Union + from sentry_sdk._types import Event, Hint + + +class DramatiqIntegration(Integration): + """ + Dramatiq integration for Sentry + + Please make sure that you call `sentry_sdk.init` *before* initializing + your broker, as it monkey patches `Broker.__init__`. + + This integration was originally developed and maintained + by https://github.com/jacobsvante and later donated to the Sentry + project. + """ + + identifier = "dramatiq" + + @staticmethod + def setup_once(): + # type: () -> None + _patch_dramatiq_broker() + + +def _patch_dramatiq_broker(): + # type: () -> None + original_broker__init__ = Broker.__init__ + + def sentry_patched_broker__init__(self, *args, **kw): + # type: (Broker, *Any, **Any) -> None + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + + try: + middleware = kw.pop("middleware") + except KeyError: + # Unfortunately Broker and StubBroker allows middleware to be + # passed in as positional arguments, whilst RabbitmqBroker and + # RedisBroker does not. + if len(args) == 1: + middleware = args[0] + args = [] # type: ignore + else: + middleware = None + + if middleware is None: + middleware = list(m() for m in default_middleware) + else: + middleware = list(middleware) + + if integration is not None: + middleware = [m for m in middleware if not isinstance(m, SentryMiddleware)] + middleware.insert(0, SentryMiddleware()) + + kw["middleware"] = middleware + original_broker__init__(self, *args, **kw) + + Broker.__init__ = sentry_patched_broker__init__ + + +class SentryMiddleware(Middleware): # type: ignore[misc] + """ + A Dramatiq middleware that automatically captures and sends + exceptions to Sentry. + + This is automatically added to every instantiated broker via the + DramatiqIntegration. + """ + + def before_process_message(self, broker, message): + # type: (Broker, Message) -> None + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + if integration is None: + return + + message._scope_manager = sentry_sdk.new_scope() + message._scope_manager.__enter__() + + scope = sentry_sdk.get_current_scope() + scope.transaction = message.actor_name + scope.set_extra("dramatiq_message_id", message.message_id) + scope.add_event_processor(_make_message_event_processor(message, integration)) + + def after_process_message(self, broker, message, *, result=None, exception=None): + # type: (Broker, Message, Any, Optional[Any], Optional[Exception]) -> None + integration = sentry_sdk.get_client().get_integration(DramatiqIntegration) + if integration is None: + return + + actor = broker.get_actor(message.actor_name) + throws = message.options.get("throws") or actor.options.get("throws") + + try: + if ( + exception is not None + and not (throws and isinstance(exception, throws)) + and not isinstance(exception, Retry) + ): + event, hint = event_from_exception( + exception, + client_options=sentry_sdk.get_client().options, + mechanism={ + "type": DramatiqIntegration.identifier, + "handled": False, + }, + ) + sentry_sdk.capture_event(event, hint=hint) + finally: + message._scope_manager.__exit__(None, None, None) + + +def _make_message_event_processor(message, integration): + # type: (Message, DramatiqIntegration) -> Callable[[Event, Hint], Optional[Event]] + + def inner(event, hint): + # type: (Event, Hint) -> Optional[Event] + with capture_internal_exceptions(): + DramatiqMessageExtractor(message).extract_into_event(event) + + return event + + return inner + + +class DramatiqMessageExtractor: + def __init__(self, message): + # type: (Message) -> None + self.message_data = dict(message.asdict()) + + def content_length(self): + # type: () -> int + return len(json.dumps(self.message_data)) + + def extract_into_event(self, event): + # type: (Event) -> None + client = sentry_sdk.get_client() + if not client.is_active(): + return + + contexts = event.setdefault("contexts", {}) + request_info = contexts.setdefault("dramatiq", {}) + request_info["type"] = "dramatiq" + + data = None # type: Optional[Union[AnnotatedValue, Dict[str, Any]]] + if not request_body_within_bounds(client, self.content_length()): + data = AnnotatedValue.removed_because_over_size_limit() + else: + data = self.message_data + + request_info["data"] = data diff --git a/sentry_sdk/integrations/excepthook.py b/sentry_sdk/integrations/excepthook.py index 58abde6614..61c7e460bf 100644 --- a/sentry_sdk/integrations/excepthook.py +++ b/sentry_sdk/integrations/excepthook.py @@ -7,7 +7,7 @@ ) from sentry_sdk.integrations import Integration -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Callable diff --git a/sentry_sdk/integrations/executing.py b/sentry_sdk/integrations/executing.py index d6817c5041..6e68b8c0c7 100644 --- a/sentry_sdk/integrations/executing.py +++ b/sentry_sdk/integrations/executing.py @@ -1,9 +1,10 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import walk_exception_chain, iter_stacks +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/integrations/falcon.py b/sentry_sdk/integrations/falcon.py index 0e0bfec9c8..ce771d16e7 100644 --- a/sentry_sdk/integrations/falcon.py +++ b/sentry_sdk/integrations/falcon.py @@ -10,7 +10,7 @@ parse_version, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -43,6 +43,12 @@ FALCON3 = False +_FALCON_UNSET = None # type: Optional[object] +if FALCON3: # falcon.request._UNSET is only available in Falcon 3.0+ + with capture_internal_exceptions(): + from falcon.request import _UNSET as _FALCON_UNSET # type: ignore[import-not-found, no-redef] + + class FalconRequestExtractor(RequestExtractor): def env(self): # type: () -> Dict[str, Any] @@ -73,27 +79,23 @@ def raw_data(self): else: return None - if FALCON3: - - def json(self): - # type: () -> Optional[Dict[str, Any]] - try: - return self.request.media - except falcon.errors.HTTPBadRequest: - return None - - else: - - def json(self): - # type: () -> Optional[Dict[str, Any]] - try: - return self.request.media - except falcon.errors.HTTPBadRequest: - # NOTE(jmagnusson): We return `falcon.Request._media` here because - # falcon 1.4 doesn't do proper type checking in - # `falcon.Request.media`. This has been fixed in 2.0. - # Relevant code: https://github.com/falconry/falcon/blob/1.4.1/falcon/request.py#L953 - return self.request._media + def json(self): + # type: () -> Optional[Dict[str, Any]] + # fallback to cached_media = None if self.request._media is not available + cached_media = None + with capture_internal_exceptions(): + # self.request._media is the cached self.request.media + # value. It is only available if self.request.media + # has already been accessed. Therefore, reading + # self.request._media will not exhaust the raw request + # stream (self.request.bounded_stream) because it has + # already been read if self.request._media is set. + cached_media = self.request._media + + if cached_media is not _FALCON_UNSET: + return cached_media + + return None class SentryFalconMiddleware: diff --git a/sentry_sdk/integrations/fastapi.py b/sentry_sdk/integrations/fastapi.py index 09784560b4..8877925a36 100644 --- a/sentry_sdk/integrations/fastapi.py +++ b/sentry_sdk/integrations/fastapi.py @@ -3,7 +3,6 @@ from functools import wraps import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import DidNotEnable from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE, TRANSACTION_SOURCE_ROUTE @@ -12,6 +11,8 @@ logger, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Dict from sentry_sdk._types import Event @@ -87,9 +88,14 @@ def _sentry_get_request_handler(*args, **kwargs): @wraps(old_call) def _sentry_call(*args, **kwargs): # type: (*Any, **Any) -> Any + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + sentry_scope = sentry_sdk.get_isolation_scope() if sentry_scope.profile is not None: sentry_scope.profile.update_active_thread_id() + return old_call(*args, **kwargs) dependant.call = _sentry_call @@ -98,10 +104,10 @@ def _sentry_call(*args, **kwargs): async def _sentry_app(*args, **kwargs): # type: (*Any, **Any) -> Any - if sentry_sdk.get_client().get_integration(FastApiIntegration) is None: + integration = sentry_sdk.get_client().get_integration(FastApiIntegration) + if integration is None: return await old_app(*args, **kwargs) - integration = sentry_sdk.get_client().get_integration(FastApiIntegration) request = args[0] _set_transaction_name_and_source( diff --git a/sentry_sdk/integrations/featureflags.py b/sentry_sdk/integrations/featureflags.py new file mode 100644 index 0000000000..46947eec72 --- /dev/null +++ b/sentry_sdk/integrations/featureflags.py @@ -0,0 +1,44 @@ +from sentry_sdk.flag_utils import flag_error_processor + +import sentry_sdk +from sentry_sdk.integrations import Integration + + +class FeatureFlagsIntegration(Integration): + """ + Sentry integration for capturing feature flags on error events. To manually buffer flag data, + call `integrations.featureflags.add_feature_flag`. We recommend you do this on each flag + evaluation. + + See the [feature flag documentation](https://develop.sentry.dev/sdk/expected-features/#feature-flags) + for more information. + + @example + ``` + import sentry_sdk + from sentry_sdk.integrations.featureflags import FeatureFlagsIntegration, add_feature_flag + + sentry_sdk.init(dsn="my_dsn", integrations=[FeatureFlagsIntegration()]); + + add_feature_flag('my-flag', true); + sentry_sdk.capture_exception(Exception('broke')); // 'my-flag' should be captured on this Sentry event. + ``` + """ + + identifier = "featureflags" + + @staticmethod + def setup_once(): + # type: () -> None + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) + + +def add_feature_flag(flag, result): + # type: (str, bool) -> None + """ + Records a flag and its value to be sent on subsequent error events by FeatureFlagsIntegration. + We recommend you do this on flag evaluations. Flags are buffered per Sentry scope. + """ + flags = sentry_sdk.get_current_scope().flags + flags.set(flag, result) diff --git a/sentry_sdk/integrations/flask.py b/sentry_sdk/integrations/flask.py index 8d82c57695..128301ddb4 100644 --- a/sentry_sdk/integrations/flask.py +++ b/sentry_sdk/integrations/flask.py @@ -1,7 +1,9 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.integrations._wsgi_common import RequestExtractor +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + RequestExtractor, +) from sentry_sdk.integrations.wsgi import SentryWsgiMiddleware from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import SOURCE_FOR_STYLE @@ -12,6 +14,8 @@ package_version, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Dict, Union @@ -51,14 +55,19 @@ class FlaskIntegration(Integration): transaction_style = "" - def __init__(self, transaction_style="endpoint"): - # type: (str) -> None + def __init__( + self, + transaction_style="endpoint", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...] + ): + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" % (transaction_style, TRANSACTION_STYLE_VALUES) ) self.transaction_style = transaction_style + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) @staticmethod def setup_once(): @@ -82,9 +91,16 @@ def sentry_patched_wsgi_app(self, environ, start_response): if sentry_sdk.get_client().get_integration(FlaskIntegration) is None: return old_app(self, environ, start_response) + integration = sentry_sdk.get_client().get_integration(FlaskIntegration) + middleware = SentryWsgiMiddleware( lambda *a, **kw: old_app(self, *a, **kw), span_origin=FlaskIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) return middleware(environ, start_response) @@ -117,10 +133,12 @@ def _set_transaction_name_and_source(scope, transaction_style, request): pass -@ensure_integration_enabled(FlaskIntegration) def _request_started(app, **kwargs): # type: (Flask, **Any) -> None integration = sentry_sdk.get_client().get_integration(FlaskIntegration) + if integration is None: + return + request = flask_request._get_current_object() # Set the transaction name and source here, diff --git a/sentry_sdk/integrations/gcp.py b/sentry_sdk/integrations/gcp.py index 86d3706fda..3983f550d3 100644 --- a/sentry_sdk/integrations/gcp.py +++ b/sentry_sdk/integrations/gcp.py @@ -1,3 +1,4 @@ +import functools import sys from copy import deepcopy from datetime import datetime, timedelta, timezone @@ -13,14 +14,13 @@ from sentry_sdk.utils import ( AnnotatedValue, capture_internal_exceptions, - ensure_integration_enabled, event_from_exception, logger, TimeoutThread, reraise, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING # Constants TIMEOUT_WARNING_BUFFER = 1.5 # Buffer time required to send timeout warning to Sentry @@ -39,12 +39,14 @@ def _wrap_func(func): # type: (F) -> F - @ensure_integration_enabled(GcpIntegration, func) + @functools.wraps(func) def sentry_func(functionhandler, gcp_event, *args, **kwargs): # type: (Any, Any, *Any, **Any) -> Any client = sentry_sdk.get_client() integration = client.get_integration(GcpIntegration) + if integration is None: + return func(functionhandler, gcp_event, *args, **kwargs) configured_time = environ.get("FUNCTION_TIMEOUT_SEC") if not configured_time: diff --git a/sentry_sdk/integrations/gnu_backtrace.py b/sentry_sdk/integrations/gnu_backtrace.py index 32d2afafbf..dc3dc80fe0 100644 --- a/sentry_sdk/integrations/gnu_backtrace.py +++ b/sentry_sdk/integrations/gnu_backtrace.py @@ -5,7 +5,7 @@ from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import capture_internal_exceptions -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index 220095f2ac..5074442986 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -16,7 +16,7 @@ except ImportError: raise DidNotEnable("gql is not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Dict, Tuple, Union diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index aa16dce92b..03731dcaaa 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -10,14 +10,13 @@ event_from_exception, package_version, ) -from sentry_sdk._types import TYPE_CHECKING - try: from graphene.types import schema as graphene_schema # type: ignore except ImportError: raise DidNotEnable("graphene is not installed") +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Generator @@ -143,9 +142,9 @@ def graphql_span(schema, source, kwargs): scope = sentry_sdk.get_current_scope() if scope.span: - _graphql_span = scope.span.start_child(op=op, description=operation_name) + _graphql_span = scope.span.start_child(op=op, name=operation_name) else: - _graphql_span = sentry_sdk.start_span(op=op, description=operation_name) + _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) _graphql_span.set_data("graphql.document", source) _graphql_span.set_data("graphql.operation.name", operation_name) diff --git a/sentry_sdk/integrations/grpc/__init__.py b/sentry_sdk/integrations/grpc/__init__.py index d84cea573f..3d949091eb 100644 --- a/sentry_sdk/integrations/grpc/__init__.py +++ b/sentry_sdk/integrations/grpc/__init__.py @@ -6,7 +6,6 @@ from grpc.aio import Server as AsyncServer from sentry_sdk.integrations import Integration -from sentry_sdk._types import TYPE_CHECKING from .client import ClientInterceptor from .server import ServerInterceptor @@ -18,7 +17,7 @@ SentryUnaryStreamClientInterceptor as AsyncUnaryStreamClientIntercetor, ) -from typing import Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Optional, Sequence # Hack to get new Python features working in older versions # without introducing a hard dependency on `typing_extensions` diff --git a/sentry_sdk/integrations/grpc/aio/__init__.py b/sentry_sdk/integrations/grpc/aio/__init__.py index 59bfd502e5..5b9e3b9949 100644 --- a/sentry_sdk/integrations/grpc/aio/__init__.py +++ b/sentry_sdk/integrations/grpc/aio/__init__.py @@ -1,2 +1,7 @@ -from .server import ServerInterceptor # noqa: F401 -from .client import ClientInterceptor # noqa: F401 +from .server import ServerInterceptor +from .client import ClientInterceptor + +__all__ = [ + "ClientInterceptor", + "ServerInterceptor", +] diff --git a/sentry_sdk/integrations/grpc/aio/client.py b/sentry_sdk/integrations/grpc/aio/client.py index 143f0e43a9..ff3c213176 100644 --- a/sentry_sdk/integrations/grpc/aio/client.py +++ b/sentry_sdk/integrations/grpc/aio/client.py @@ -6,6 +6,7 @@ ClientCallDetails, UnaryUnaryCall, UnaryStreamCall, + Metadata, ) from google.protobuf.message import Message @@ -19,23 +20,19 @@ class ClientInterceptor: def _update_client_call_details_metadata_from_scope( client_call_details: ClientCallDetails, ) -> ClientCallDetails: - metadata = ( - list(client_call_details.metadata) if client_call_details.metadata else [] - ) + if client_call_details.metadata is None: + client_call_details = client_call_details._replace(metadata=Metadata()) + elif not isinstance(client_call_details.metadata, Metadata): + # This is a workaround for a GRPC bug, which was fixed in grpcio v1.60.0 + # See https://github.com/grpc/grpc/issues/34298. + client_call_details = client_call_details._replace( + metadata=Metadata.from_tuple(client_call_details.metadata) + ) for ( key, value, ) in sentry_sdk.get_current_scope().iter_trace_propagation_headers(): - metadata.append((key, value)) - - client_call_details = ClientCallDetails( - method=client_call_details.method, - timeout=client_call_details.timeout, - metadata=metadata, - credentials=client_call_details.credentials, - wait_for_ready=client_call_details.wait_for_ready, - ) - + client_call_details.metadata.add(key, value) return client_call_details @@ -50,7 +47,7 @@ async def intercept_unary_unary( with sentry_sdk.start_span( op=OP.GRPC_CLIENT, - description="unary unary call to %s" % method.decode(), + name="unary unary call to %s" % method.decode(), origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary unary") @@ -80,7 +77,7 @@ async def intercept_unary_stream( with sentry_sdk.start_span( op=OP.GRPC_CLIENT, - description="unary stream call to %s" % method.decode(), + name="unary stream call to %s" % method.decode(), origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary stream") diff --git a/sentry_sdk/integrations/grpc/aio/server.py b/sentry_sdk/integrations/grpc/aio/server.py index 2fdcb0b8f0..addc6bee36 100644 --- a/sentry_sdk/integrations/grpc/aio/server.py +++ b/sentry_sdk/integrations/grpc/aio/server.py @@ -1,11 +1,12 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM from sentry_sdk.utils import event_from_exception +from typing import TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Awaitable, Callable from typing import Any, Optional diff --git a/sentry_sdk/integrations/grpc/client.py b/sentry_sdk/integrations/grpc/client.py index c12f0ab2c4..a5b4f9f52e 100644 --- a/sentry_sdk/integrations/grpc/client.py +++ b/sentry_sdk/integrations/grpc/client.py @@ -1,9 +1,10 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Iterator, Iterable, Union @@ -28,7 +29,7 @@ def intercept_unary_unary(self, continuation, client_call_details, request): with sentry_sdk.start_span( op=OP.GRPC_CLIENT, - description="unary unary call to %s" % method, + name="unary unary call to %s" % method, origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary unary") @@ -49,7 +50,7 @@ def intercept_unary_stream(self, continuation, client_call_details, request): with sentry_sdk.start_span( op=OP.GRPC_CLIENT, - description="unary stream call to %s" % method, + name="unary stream call to %s" % method, origin=SPAN_ORIGIN, ) as span: span.set_data("type", "unary stream") diff --git a/sentry_sdk/integrations/grpc/server.py b/sentry_sdk/integrations/grpc/server.py index 74ab550529..a640df5e11 100644 --- a/sentry_sdk/integrations/grpc/server.py +++ b/sentry_sdk/integrations/grpc/server.py @@ -1,10 +1,11 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable from sentry_sdk.integrations.grpc.consts import SPAN_ORIGIN from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_CUSTOM +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Callable, Optional from google.protobuf.message import Message diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index d35990cb30..2ddd44489f 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -2,7 +2,7 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import BAGGAGE_HEADER_NAME -from sentry_sdk.tracing_utils import should_propagate_trace +from sentry_sdk.tracing_utils import Baggage, should_propagate_trace from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, capture_internal_exceptions, @@ -11,9 +11,10 @@ parse_url, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import MutableMapping from typing import Any @@ -53,7 +54,7 @@ def send(self, request, **kwargs): with sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % ( request.method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, @@ -76,11 +77,9 @@ def send(self, request, **kwargs): key=key, value=value, url=request.url ) ) - if key == BAGGAGE_HEADER_NAME and request.headers.get( - BAGGAGE_HEADER_NAME - ): - # do not overwrite any existing baggage, just append to it - request.headers[key] += "," + value + + if key == BAGGAGE_HEADER_NAME: + _add_sentry_baggage_to_headers(request.headers, value) else: request.headers[key] = value @@ -109,7 +108,7 @@ async def send(self, request, **kwargs): with sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % ( request.method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE, @@ -148,3 +147,21 @@ async def send(self, request, **kwargs): return rv AsyncClient.send = send + + +def _add_sentry_baggage_to_headers(headers, sentry_baggage): + # type: (MutableMapping[str, str], str) -> None + """Add the Sentry baggage to the headers. + + This function directly mutates the provided headers. The provided sentry_baggage + is appended to the existing baggage. If the baggage already contains Sentry items, + they are stripped out first. + """ + existing_baggage = headers.get(BAGGAGE_HEADER_NAME, "") + stripped_existing_baggage = Baggage.strip_sentry_baggage(existing_baggage) + + separator = "," if len(stripped_existing_baggage) > 0 else "" + + headers[BAGGAGE_HEADER_NAME] = ( + stripped_existing_baggage + separator + sentry_baggage + ) diff --git a/sentry_sdk/integrations/huey.py b/sentry_sdk/integrations/huey.py index 21ccf95813..7db57680f6 100644 --- a/sentry_sdk/integrations/huey.py +++ b/sentry_sdk/integrations/huey.py @@ -2,7 +2,6 @@ from datetime import datetime import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.api import continue_trace, get_baggage, get_traceparent from sentry_sdk.consts import OP, SPANSTATUS from sentry_sdk.integrations import DidNotEnable, Integration @@ -20,6 +19,8 @@ reraise, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Callable, Optional, Union, TypeVar @@ -58,7 +59,7 @@ def _sentry_enqueue(self, task): # type: (Huey, Task) -> Optional[Union[Result, ResultGroup]] with sentry_sdk.start_span( op=OP.QUEUE_SUBMIT_HUEY, - description=task.name, + name=task.name, origin=HueyIntegration.origin, ): if not isinstance(task, PeriodicTask): diff --git a/sentry_sdk/integrations/huggingface_hub.py b/sentry_sdk/integrations/huggingface_hub.py index c7ed6907dd..d09f6e2163 100644 --- a/sentry_sdk/integrations/huggingface_hub.py +++ b/sentry_sdk/integrations/huggingface_hub.py @@ -13,7 +13,6 @@ from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, - ensure_integration_enabled, ) try: @@ -55,9 +54,12 @@ def _capture_exception(exc): def _wrap_text_generation(f): # type: (Callable[..., Any]) -> Callable[..., Any] @wraps(f) - @ensure_integration_enabled(HuggingfaceHubIntegration, f) def new_text_generation(*args, **kwargs): # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(HuggingfaceHubIntegration) + if integration is None: + return f(*args, **kwargs) + if "prompt" in kwargs: prompt = kwargs["prompt"] elif len(args) >= 2: @@ -73,7 +75,7 @@ def new_text_generation(*args, **kwargs): span = sentry_sdk.start_span( op=consts.OP.HUGGINGFACE_HUB_CHAT_COMPLETIONS_CREATE, - description="Text Generation", + name="Text Generation", origin=HuggingfaceHubIntegration.origin, ) span.__enter__() @@ -84,8 +86,6 @@ def new_text_generation(*args, **kwargs): span.__exit__(None, None, None) raise e from None - integration = sentry_sdk.get_client().get_integration(HuggingfaceHubIntegration) - with capture_internal_exceptions(): if should_send_default_pii() and integration.include_prompts: set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, prompt) diff --git a/sentry_sdk/integrations/langchain.py b/sentry_sdk/integrations/langchain.py index 60c791fa12..431fc46bec 100644 --- a/sentry_sdk/integrations/langchain.py +++ b/sentry_sdk/integrations/langchain.py @@ -2,18 +2,19 @@ from functools import wraps import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.ai.monitoring import set_ai_pipeline_name, record_token_usage from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.ai.utils import set_data_normalized from sentry_sdk.scope import should_send_default_pii from sentry_sdk.tracing import Span +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.utils import logger, capture_internal_exceptions + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, List, Callable, Dict, Union, Optional from uuid import UUID -from sentry_sdk.integrations import DidNotEnable, Integration -from sentry_sdk.utils import logger, capture_internal_exceptions try: from langchain_core.messages import BaseMessage @@ -137,7 +138,7 @@ def _create_span(self, run_id, parent_id, **kwargs): watched_span = None # type: Optional[WatchedSpan] if parent_id: - parent_span = self.span_map[parent_id] # type: Optional[WatchedSpan] + parent_span = self.span_map.get(parent_id) # type: Optional[WatchedSpan] if parent_span: watched_span = WatchedSpan(parent_span.span.start_child(**kwargs)) parent_span.children.append(watched_span) @@ -145,8 +146,8 @@ def _create_span(self, run_id, parent_id, **kwargs): watched_span = WatchedSpan(sentry_sdk.start_span(**kwargs)) if kwargs.get("op", "").startswith("ai.pipeline."): - if kwargs.get("description"): - set_ai_pipeline_name(kwargs.get("description")) + if kwargs.get("name"): + set_ai_pipeline_name(kwargs.get("name")) watched_span.is_pipeline = True watched_span.span.__enter__() @@ -185,7 +186,7 @@ def on_llm_start( run_id, kwargs.get("parent_run_id"), op=OP.LANGCHAIN_RUN, - description=kwargs.get("name") or "Langchain LLM call", + name=kwargs.get("name") or "Langchain LLM call", origin=LangchainIntegration.origin, ) span = watched_span.span @@ -207,7 +208,7 @@ def on_chat_model_start(self, serialized, messages, *, run_id, **kwargs): run_id, kwargs.get("parent_run_id"), op=OP.LANGCHAIN_CHAT_COMPLETIONS_CREATE, - description=kwargs.get("name") or "Langchain Chat Model", + name=kwargs.get("name") or "Langchain Chat Model", origin=LangchainIntegration.origin, ) span = watched_span.span @@ -311,7 +312,7 @@ def on_chain_start(self, serialized, inputs, *, run_id, **kwargs): if kwargs.get("parent_run_id") is not None else OP.LANGCHAIN_PIPELINE ), - description=kwargs.get("name") or "Chain execution", + name=kwargs.get("name") or "Chain execution", origin=LangchainIntegration.origin, ) metadata = kwargs.get("metadata") @@ -344,7 +345,7 @@ def on_agent_action(self, action, *, run_id, **kwargs): run_id, kwargs.get("parent_run_id"), op=OP.LANGCHAIN_AGENT, - description=action.tool or "AI tool usage", + name=action.tool or "AI tool usage", origin=LangchainIntegration.origin, ) if action.tool_input and should_send_default_pii() and self.include_prompts: @@ -377,9 +378,7 @@ def on_tool_start(self, serialized, input_str, *, run_id, **kwargs): run_id, kwargs.get("parent_run_id"), op=OP.LANGCHAIN_TOOL, - description=serialized.get("name") - or kwargs.get("name") - or "AI tool usage", + name=serialized.get("name") or kwargs.get("name") or "AI tool usage", origin=LangchainIntegration.origin, ) if should_send_default_pii() and self.include_prompts: @@ -421,6 +420,8 @@ def new_configure(*args, **kwargs): # type: (Any, Any) -> Any integration = sentry_sdk.get_client().get_integration(LangchainIntegration) + if integration is None: + return f(*args, **kwargs) with capture_internal_exceptions(): new_callbacks = [] # type: List[BaseCallbackHandler] @@ -444,7 +445,7 @@ def new_configure(*args, **kwargs): elif isinstance(existing_callbacks, BaseCallbackHandler): new_callbacks.append(existing_callbacks) else: - logger.warn("Unknown callback type: %s", existing_callbacks) + logger.debug("Unknown callback type: %s", existing_callbacks) already_added = False for callback in new_callbacks: diff --git a/sentry_sdk/integrations/launchdarkly.py b/sentry_sdk/integrations/launchdarkly.py new file mode 100644 index 0000000000..a9eef9e1a9 --- /dev/null +++ b/sentry_sdk/integrations/launchdarkly.py @@ -0,0 +1,64 @@ +from typing import TYPE_CHECKING +import sentry_sdk + +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.flag_utils import flag_error_processor + +try: + import ldclient + from ldclient.hook import Hook, Metadata + + if TYPE_CHECKING: + from ldclient import LDClient + from ldclient.hook import EvaluationSeriesContext + from ldclient.evaluation import EvaluationDetail + + from typing import Any +except ImportError: + raise DidNotEnable("LaunchDarkly is not installed") + + +class LaunchDarklyIntegration(Integration): + identifier = "launchdarkly" + + def __init__(self, ld_client=None): + # type: (LDClient | None) -> None + """ + :param client: An initialized LDClient instance. If a client is not provided, this + integration will attempt to use the shared global instance. + """ + try: + client = ld_client or ldclient.get() + except Exception as exc: + raise DidNotEnable("Error getting LaunchDarkly client. " + repr(exc)) + + if not client.is_initialized(): + raise DidNotEnable("LaunchDarkly client is not initialized.") + + # Register the flag collection hook with the LD client. + client.add_hook(LaunchDarklyHook()) + + @staticmethod + def setup_once(): + # type: () -> None + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) + + +class LaunchDarklyHook(Hook): + + @property + def metadata(self): + # type: () -> Metadata + return Metadata(name="sentry-flag-auditor") + + def after_evaluation(self, series_context, data, detail): + # type: (EvaluationSeriesContext, dict[Any, Any], EvaluationDetail) -> dict[Any, Any] + if isinstance(detail.value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(series_context.key, detail.value) + return data + + def before_evaluation(self, series_context, data): + # type: (EvaluationSeriesContext, dict[Any, Any]) -> dict[Any, Any] + return data # No-op. diff --git a/sentry_sdk/integrations/litestar.py b/sentry_sdk/integrations/litestar.py index 8eb3b44ca4..4b04dada8a 100644 --- a/sentry_sdk/integrations/litestar.py +++ b/sentry_sdk/integrations/litestar.py @@ -1,5 +1,4 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware @@ -20,6 +19,9 @@ from litestar.data_extractors import ConnectionDataExtractor # type: ignore except ImportError: raise DidNotEnable("Litestar is not installed") + +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Optional, Union from litestar.types.asgi_types import ASGIApp # type: ignore @@ -137,7 +139,7 @@ async def _create_span_call(self, scope, receive, send): middleware_name = self.__class__.__name__ with sentry_sdk.start_span( op=OP.MIDDLEWARE_LITESTAR, - description=middleware_name, + name=middleware_name, origin=LitestarIntegration.origin, ) as middleware_span: middleware_span.set_tag("litestar.middleware_name", middleware_name) @@ -149,7 +151,7 @@ async def _sentry_receive(*args, **kwargs): return await receive(*args, **kwargs) with sentry_sdk.start_span( op=OP.MIDDLEWARE_LITESTAR_RECEIVE, - description=getattr(receive, "__qualname__", str(receive)), + name=getattr(receive, "__qualname__", str(receive)), origin=LitestarIntegration.origin, ) as span: span.set_tag("litestar.middleware_name", middleware_name) @@ -166,7 +168,7 @@ async def _sentry_send(message): return await send(message) with sentry_sdk.start_span( op=OP.MIDDLEWARE_LITESTAR_SEND, - description=getattr(send, "__qualname__", str(send)), + name=getattr(send, "__qualname__", str(send)), origin=LitestarIntegration.origin, ) as span: span.set_tag("litestar.middleware_name", middleware_name) diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 231ec5d80e..b792510d6c 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -10,7 +10,8 @@ capture_internal_exceptions, ) from sentry_sdk.integrations import Integration -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import MutableMapping @@ -110,7 +111,7 @@ def sentry_patched_callhandlers(self, record): logging.Logger.callHandlers = sentry_patched_callhandlers # type: ignore -class _BaseHandler(logging.Handler, object): +class _BaseHandler(logging.Handler): COMMON_RECORD_ATTRS = frozenset( ( "args", @@ -201,7 +202,7 @@ def _emit(self, record): client_options=client_options, mechanism={"type": "logging", "handled": True}, ) - elif record.exc_info and record.exc_info[0] is None: + elif (record.exc_info and record.exc_info[0] is None) or record.stack_info: event = {} hint = {} with capture_internal_exceptions(): diff --git a/sentry_sdk/integrations/loguru.py b/sentry_sdk/integrations/loguru.py index 99f2dfd5ac..da99dfc4d6 100644 --- a/sentry_sdk/integrations/loguru.py +++ b/sentry_sdk/integrations/loguru.py @@ -1,6 +1,5 @@ import enum -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ( BreadcrumbHandler, @@ -8,6 +7,8 @@ _BaseHandler, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from logging import LogRecord from typing import Optional, Tuple diff --git a/sentry_sdk/integrations/modules.py b/sentry_sdk/integrations/modules.py index 6376d25a30..ce3ee78665 100644 --- a/sentry_sdk/integrations/modules.py +++ b/sentry_sdk/integrations/modules.py @@ -3,7 +3,7 @@ from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import _get_installed_modules -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/openai.py b/sentry_sdk/integrations/openai.py index d06c188712..61d335b170 100644 --- a/sentry_sdk/integrations/openai.py +++ b/sentry_sdk/integrations/openai.py @@ -1,27 +1,26 @@ from functools import wraps +import sentry_sdk from sentry_sdk import consts -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.ai.monitoring import record_token_usage -from sentry_sdk.consts import SPANDATA from sentry_sdk.ai.utils import set_data_normalized - -if TYPE_CHECKING: - from typing import Any, Iterable, List, Optional, Callable, Iterator - from sentry_sdk.tracing import Span - -import sentry_sdk -from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, - ensure_integration_enabled, ) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Any, Iterable, List, Optional, Callable, AsyncIterator, Iterator + from sentry_sdk.tracing import Span + try: - from openai.resources.chat.completions import Completions - from openai.resources import Embeddings + from openai.resources.chat.completions import Completions, AsyncCompletions + from openai.resources import Embeddings, AsyncEmbeddings if TYPE_CHECKING: from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk @@ -49,6 +48,11 @@ def setup_once(): Completions.create = _wrap_chat_completion_create(Completions.create) Embeddings.create = _wrap_embeddings_create(Embeddings.create) + AsyncCompletions.create = _wrap_async_chat_completion_create( + AsyncCompletions.create + ) + AsyncEmbeddings.create = _wrap_async_embeddings_create(AsyncEmbeddings.create) + def count_tokens(self, s): # type: (OpenAIIntegration, str) -> int if self.tiktoken_encoding is not None: @@ -110,159 +114,316 @@ def _calculate_chat_completion_usage( record_token_usage(span, prompt_tokens, completion_tokens, total_tokens) -def _wrap_chat_completion_create(f): - # type: (Callable[..., Any]) -> Callable[..., Any] +def _new_chat_completion_common(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) - @ensure_integration_enabled(OpenAIIntegration, f) - def new_chat_completion(*args, **kwargs): - # type: (*Any, **Any) -> Any - if "messages" not in kwargs: - # invalid call (in all versions of openai), let it return error - return f(*args, **kwargs) + if "messages" not in kwargs: + # invalid call (in all versions of openai), let it return error + return f(*args, **kwargs) - try: - iter(kwargs["messages"]) - except TypeError: - # invalid call (in all versions), messages must be iterable - return f(*args, **kwargs) + try: + iter(kwargs["messages"]) + except TypeError: + # invalid call (in all versions), messages must be iterable + return f(*args, **kwargs) - kwargs["messages"] = list(kwargs["messages"]) - messages = kwargs["messages"] - model = kwargs.get("model") - streaming = kwargs.get("stream") + kwargs["messages"] = list(kwargs["messages"]) + messages = kwargs["messages"] + model = kwargs.get("model") + streaming = kwargs.get("stream") - span = sentry_sdk.start_span( - op=consts.OP.OPENAI_CHAT_COMPLETIONS_CREATE, - description="Chat Completion", - origin=OpenAIIntegration.origin, - ) - span.__enter__() - try: - res = f(*args, **kwargs) - except Exception as e: - _capture_exception(e) - span.__exit__(None, None, None) - raise e from None + span = sentry_sdk.start_span( + op=consts.OP.OPENAI_CHAT_COMPLETIONS_CREATE, + name="Chat Completion", + origin=OpenAIIntegration.origin, + ) + span.__enter__() - integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + res = yield f, args, kwargs + + with capture_internal_exceptions(): + if should_send_default_pii() and integration.include_prompts: + set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, messages) - with capture_internal_exceptions(): + set_data_normalized(span, SPANDATA.AI_MODEL_ID, model) + set_data_normalized(span, SPANDATA.AI_STREAMING, streaming) + + if hasattr(res, "choices"): if should_send_default_pii() and integration.include_prompts: - set_data_normalized(span, SPANDATA.AI_INPUT_MESSAGES, messages) - - set_data_normalized(span, SPANDATA.AI_MODEL_ID, model) - set_data_normalized(span, SPANDATA.AI_STREAMING, streaming) - - if hasattr(res, "choices"): - if should_send_default_pii() and integration.include_prompts: - set_data_normalized( - span, - "ai.responses", - list(map(lambda x: x.message, res.choices)), - ) - _calculate_chat_completion_usage( - messages, res, span, None, integration.count_tokens + set_data_normalized( + span, + "ai.responses", + list(map(lambda x: x.message, res.choices)), ) - span.__exit__(None, None, None) - elif hasattr(res, "_iterator"): - data_buf: list[list[str]] = [] # one for each choice - - old_iterator = res._iterator # type: Iterator[ChatCompletionChunk] - - def new_iterator(): - # type: () -> Iterator[ChatCompletionChunk] - with capture_internal_exceptions(): - for x in old_iterator: - if hasattr(x, "choices"): - choice_index = 0 - for choice in x.choices: - if hasattr(choice, "delta") and hasattr( - choice.delta, "content" - ): - content = choice.delta.content - if len(data_buf) <= choice_index: - data_buf.append([]) - data_buf[choice_index].append(content or "") - choice_index += 1 - yield x - if len(data_buf) > 0: - all_responses = list( - map(lambda chunk: "".join(chunk), data_buf) + _calculate_chat_completion_usage( + messages, res, span, None, integration.count_tokens + ) + span.__exit__(None, None, None) + elif hasattr(res, "_iterator"): + data_buf: list[list[str]] = [] # one for each choice + + old_iterator = res._iterator + + def new_iterator(): + # type: () -> Iterator[ChatCompletionChunk] + with capture_internal_exceptions(): + for x in old_iterator: + if hasattr(x, "choices"): + choice_index = 0 + for choice in x.choices: + if hasattr(choice, "delta") and hasattr( + choice.delta, "content" + ): + content = choice.delta.content + if len(data_buf) <= choice_index: + data_buf.append([]) + data_buf[choice_index].append(content or "") + choice_index += 1 + yield x + if len(data_buf) > 0: + all_responses = list( + map(lambda chunk: "".join(chunk), data_buf) + ) + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, SPANDATA.AI_RESPONSES, all_responses ) - if ( - should_send_default_pii() - and integration.include_prompts - ): - set_data_normalized( - span, SPANDATA.AI_RESPONSES, all_responses - ) - _calculate_chat_completion_usage( - messages, - res, - span, - all_responses, - integration.count_tokens, + _calculate_chat_completion_usage( + messages, + res, + span, + all_responses, + integration.count_tokens, + ) + span.__exit__(None, None, None) + + async def new_iterator_async(): + # type: () -> AsyncIterator[ChatCompletionChunk] + with capture_internal_exceptions(): + async for x in old_iterator: + if hasattr(x, "choices"): + choice_index = 0 + for choice in x.choices: + if hasattr(choice, "delta") and hasattr( + choice.delta, "content" + ): + content = choice.delta.content + if len(data_buf) <= choice_index: + data_buf.append([]) + data_buf[choice_index].append(content or "") + choice_index += 1 + yield x + if len(data_buf) > 0: + all_responses = list( + map(lambda chunk: "".join(chunk), data_buf) + ) + if should_send_default_pii() and integration.include_prompts: + set_data_normalized( + span, SPANDATA.AI_RESPONSES, all_responses ) - span.__exit__(None, None, None) + _calculate_chat_completion_usage( + messages, + res, + span, + all_responses, + integration.count_tokens, + ) + span.__exit__(None, None, None) - res._iterator = new_iterator() + if str(type(res._iterator)) == "": + res._iterator = new_iterator_async() else: - set_data_normalized(span, "unknown_response", True) - span.__exit__(None, None, None) - return res + res._iterator = new_iterator() - return new_chat_completion + else: + set_data_normalized(span, "unknown_response", True) + span.__exit__(None, None, None) + return res -def _wrap_embeddings_create(f): +def _wrap_chat_completion_create(f): # type: (Callable[..., Any]) -> Callable[..., Any] + def _execute_sync(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _new_chat_completion_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: + try: + result = f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value @wraps(f) - @ensure_integration_enabled(OpenAIIntegration, f) - def new_embeddings_create(*args, **kwargs): + def _sentry_patched_create_sync(*args, **kwargs): # type: (*Any, **Any) -> Any - with sentry_sdk.start_span( - op=consts.OP.OPENAI_EMBEDDINGS_CREATE, - description="OpenAI Embedding Creation", - origin=OpenAIIntegration.origin, - ) as span: - integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) - if "input" in kwargs and ( - should_send_default_pii() and integration.include_prompts + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None or "messages" not in kwargs: + # no "messages" means invalid call (in all versions of openai), let it return error + return f(*args, **kwargs) + + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync + + +def _wrap_async_chat_completion_create(f): + # type: (Callable[..., Any]) -> Callable[..., Any] + async def _execute_async(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _new_chat_completion_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_create_async(*args, **kwargs): + # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None or "messages" not in kwargs: + # no "messages" means invalid call (in all versions of openai), let it return error + return await f(*args, **kwargs) + + return await _execute_async(f, *args, **kwargs) + + return _sentry_patched_create_async + + +def _new_embeddings_create_common(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + with sentry_sdk.start_span( + op=consts.OP.OPENAI_EMBEDDINGS_CREATE, + description="OpenAI Embedding Creation", + origin=OpenAIIntegration.origin, + ) as span: + if "input" in kwargs and ( + should_send_default_pii() and integration.include_prompts + ): + if isinstance(kwargs["input"], str): + set_data_normalized(span, "ai.input_messages", [kwargs["input"]]) + elif ( + isinstance(kwargs["input"], list) + and len(kwargs["input"]) > 0 + and isinstance(kwargs["input"][0], str) ): - if isinstance(kwargs["input"], str): - set_data_normalized(span, "ai.input_messages", [kwargs["input"]]) - elif ( - isinstance(kwargs["input"], list) - and len(kwargs["input"]) > 0 - and isinstance(kwargs["input"][0], str) - ): - set_data_normalized(span, "ai.input_messages", kwargs["input"]) - if "model" in kwargs: - set_data_normalized(span, "ai.model_id", kwargs["model"]) + set_data_normalized(span, "ai.input_messages", kwargs["input"]) + if "model" in kwargs: + set_data_normalized(span, "ai.model_id", kwargs["model"]) + + response = yield f, args, kwargs + + prompt_tokens = 0 + total_tokens = 0 + if hasattr(response, "usage"): + if hasattr(response.usage, "prompt_tokens") and isinstance( + response.usage.prompt_tokens, int + ): + prompt_tokens = response.usage.prompt_tokens + if hasattr(response.usage, "total_tokens") and isinstance( + response.usage.total_tokens, int + ): + total_tokens = response.usage.total_tokens + + if prompt_tokens == 0: + prompt_tokens = integration.count_tokens(kwargs["input"] or "") + + record_token_usage(span, prompt_tokens, None, total_tokens or prompt_tokens) + + return response + + +def _wrap_embeddings_create(f): + # type: (Any) -> Any + def _execute_sync(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _new_embeddings_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return e.value + + try: try: - response = f(*args, **kwargs) + result = f(*args, **kwargs) except Exception as e: _capture_exception(e) raise e from None - prompt_tokens = 0 - total_tokens = 0 - if hasattr(response, "usage"): - if hasattr(response.usage, "prompt_tokens") and isinstance( - response.usage.prompt_tokens, int - ): - prompt_tokens = response.usage.prompt_tokens - if hasattr(response.usage, "total_tokens") and isinstance( - response.usage.total_tokens, int - ): - total_tokens = response.usage.total_tokens + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + def _sentry_patched_create_sync(*args, **kwargs): + # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return f(*args, **kwargs) + + return _execute_sync(f, *args, **kwargs) + + return _sentry_patched_create_sync - if prompt_tokens == 0: - prompt_tokens = integration.count_tokens(kwargs["input"] or "") - record_token_usage(span, prompt_tokens, None, total_tokens or prompt_tokens) +def _wrap_async_embeddings_create(f): + # type: (Any) -> Any + async def _execute_async(f, *args, **kwargs): + # type: (Any, *Any, **Any) -> Any + gen = _new_embeddings_create_common(f, *args, **kwargs) + + try: + f, args, kwargs = next(gen) + except StopIteration as e: + return await e.value + + try: + try: + result = await f(*args, **kwargs) + except Exception as e: + _capture_exception(e) + raise e from None + + return gen.send(result) + except StopIteration as e: + return e.value + + @wraps(f) + async def _sentry_patched_create_async(*args, **kwargs): + # type: (*Any, **Any) -> Any + integration = sentry_sdk.get_client().get_integration(OpenAIIntegration) + if integration is None: + return await f(*args, **kwargs) - return response + return await _execute_async(f, *args, **kwargs) - return new_embeddings_create + return _sentry_patched_create_async diff --git a/sentry_sdk/integrations/openfeature.py b/sentry_sdk/integrations/openfeature.py new file mode 100644 index 0000000000..18f968a703 --- /dev/null +++ b/sentry_sdk/integrations/openfeature.py @@ -0,0 +1,43 @@ +from typing import TYPE_CHECKING +import sentry_sdk + +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.flag_utils import flag_error_processor + +try: + from openfeature import api + from openfeature.hook import Hook + + if TYPE_CHECKING: + from openfeature.flag_evaluation import FlagEvaluationDetails + from openfeature.hook import HookContext, HookHints +except ImportError: + raise DidNotEnable("OpenFeature is not installed") + + +class OpenFeatureIntegration(Integration): + identifier = "openfeature" + + @staticmethod + def setup_once(): + # type: () -> None + scope = sentry_sdk.get_current_scope() + scope.add_error_processor(flag_error_processor) + + # Register the hook within the global openfeature hooks list. + api.add_hooks(hooks=[OpenFeatureHook()]) + + +class OpenFeatureHook(Hook): + + def after(self, hook_context, details, hints): + # type: (HookContext, FlagEvaluationDetails[bool], HookHints) -> None + if isinstance(details.value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(details.flag_key, details.value) + + def error(self, hook_context, exception, hints): + # type: (HookContext, Exception, HookHints) -> None + if isinstance(hook_context.default_value, bool): + flags = sentry_sdk.get_current_scope().flags + flags.set(hook_context.flag_key, hook_context.default_value) diff --git a/sentry_sdk/integrations/opentelemetry/__init__.py b/sentry_sdk/integrations/opentelemetry/__init__.py index e0020204d5..3c4c1a683d 100644 --- a/sentry_sdk/integrations/opentelemetry/__init__.py +++ b/sentry_sdk/integrations/opentelemetry/__init__.py @@ -1,7 +1,7 @@ -from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401 - SentrySpanProcessor, -) +from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor +from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator -from sentry_sdk.integrations.opentelemetry.propagator import ( # noqa: F401 - SentryPropagator, -) +__all__ = [ + "SentryPropagator", + "SentrySpanProcessor", +] diff --git a/sentry_sdk/integrations/opentelemetry/propagator.py b/sentry_sdk/integrations/opentelemetry/propagator.py index 3df2ee2f2f..b84d582d6e 100644 --- a/sentry_sdk/integrations/opentelemetry/propagator.py +++ b/sentry_sdk/integrations/opentelemetry/propagator.py @@ -18,7 +18,6 @@ TraceFlags, ) -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations.opentelemetry.consts import ( SENTRY_BAGGAGE_KEY, SENTRY_TRACE_KEY, @@ -32,6 +31,8 @@ ) from sentry_sdk.tracing_utils import Baggage, extract_sentrytrace_data +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional, Set diff --git a/sentry_sdk/integrations/opentelemetry/span_processor.py b/sentry_sdk/integrations/opentelemetry/span_processor.py index d54372b374..e00562a509 100644 --- a/sentry_sdk/integrations/opentelemetry/span_processor.py +++ b/sentry_sdk/integrations/opentelemetry/span_processor.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone from time import time -from typing import cast +from typing import TYPE_CHECKING, cast from opentelemetry.context import get_value from opentelemetry.sdk.trace import SpanProcessor, ReadableSpan as OTelSpan @@ -24,7 +24,6 @@ from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing import Transaction, Span as SentrySpan from sentry_sdk.utils import Dsn -from sentry_sdk._types import TYPE_CHECKING from urllib3.util import parse_url as urlparse @@ -148,7 +147,7 @@ def on_start(self, otel_span, parent_context=None): if sentry_parent_span: sentry_span = sentry_parent_span.start_child( span_id=trace_data["span_id"], - description=otel_span.name, + name=otel_span.name, start_timestamp=start_timestamp, instrumenter=INSTRUMENTER.OTEL, origin=SPAN_ORIGIN, diff --git a/sentry_sdk/integrations/pure_eval.py b/sentry_sdk/integrations/pure_eval.py index 9af4831b32..c1c3d63871 100644 --- a/sentry_sdk/integrations/pure_eval.py +++ b/sentry_sdk/integrations/pure_eval.py @@ -2,11 +2,12 @@ import sentry_sdk from sentry_sdk import serializer -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import walk_exception_chain, iter_stacks +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional, Dict, Any, Tuple, List from types import FrameType @@ -131,7 +132,8 @@ def start(n): atok = source.asttokens() expressions.sort(key=closeness, reverse=True) - return { + vars = { atok.get_text(nodes[0]): value for nodes, value in expressions[: serializer.MAX_DATABAG_BREADTH] } + return serializer.serialize(vars, is_vars=True) diff --git a/sentry_sdk/integrations/pymongo.py b/sentry_sdk/integrations/pymongo.py index 08d9cf84cd..f65ad73687 100644 --- a/sentry_sdk/integrations/pymongo.py +++ b/sentry_sdk/integrations/pymongo.py @@ -8,13 +8,13 @@ from sentry_sdk.tracing import Span from sentry_sdk.utils import capture_internal_exceptions -from sentry_sdk._types import TYPE_CHECKING - try: from pymongo import monitoring except ImportError: raise DidNotEnable("Pymongo not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Dict, Union @@ -158,7 +158,7 @@ def started(self, event): query = json.dumps(command, default=str) span = sentry_sdk.start_span( op=OP.DB, - description=query, + name=query, origin=PyMongoIntegration.origin, ) diff --git a/sentry_sdk/integrations/pyramid.py b/sentry_sdk/integrations/pyramid.py index 887837c0d6..d1475ada65 100644 --- a/sentry_sdk/integrations/pyramid.py +++ b/sentry_sdk/integrations/pyramid.py @@ -1,3 +1,4 @@ +import functools import os import sys import weakref @@ -14,7 +15,6 @@ event_from_exception, reraise, ) -from sentry_sdk._types import TYPE_CHECKING try: from pyramid.httpexceptions import HTTPException @@ -22,6 +22,7 @@ except ImportError: raise DidNotEnable("Pyramid not installed") +from typing import TYPE_CHECKING if TYPE_CHECKING: from pyramid.response import Response @@ -73,10 +74,12 @@ def setup_once(): old_call_view = router._call_view - @ensure_integration_enabled(PyramidIntegration, old_call_view) + @functools.wraps(old_call_view) def sentry_patched_call_view(registry, request, *args, **kwargs): # type: (Any, Request, *Any, **Any) -> Response integration = sentry_sdk.get_client().get_integration(PyramidIntegration) + if integration is None: + return old_call_view(registry, request, *args, **kwargs) _set_transaction_name_and_source( sentry_sdk.get_current_scope(), integration.transaction_style, request diff --git a/sentry_sdk/integrations/quart.py b/sentry_sdk/integrations/quart.py index 0689406672..51306bb4cd 100644 --- a/sentry_sdk/integrations/quart.py +++ b/sentry_sdk/integrations/quart.py @@ -1,6 +1,5 @@ import asyncio import inspect -import threading from functools import wraps import sentry_sdk @@ -14,7 +13,7 @@ ensure_integration_enabled, event_from_exception, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -122,11 +121,13 @@ def decorator(old_func): @ensure_integration_enabled(QuartIntegration, old_func) def _sentry_func(*args, **kwargs): # type: (*Any, **Any) -> Any - scope = sentry_sdk.get_isolation_scope() - if scope.profile is not None: - scope.profile.active_thread_id = ( - threading.current_thread().ident - ) + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + + sentry_scope = sentry_sdk.get_isolation_scope() + if sentry_scope.profile is not None: + sentry_scope.profile.update_active_thread_id() return old_func(*args, **kwargs) diff --git a/sentry_sdk/integrations/ray.py b/sentry_sdk/integrations/ray.py new file mode 100644 index 0000000000..2f5086ed92 --- /dev/null +++ b/sentry_sdk/integrations/ray.py @@ -0,0 +1,146 @@ +import inspect +import sys + +import sentry_sdk +from sentry_sdk.consts import OP, SPANSTATUS +from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.tracing import TRANSACTION_SOURCE_TASK +from sentry_sdk.utils import ( + event_from_exception, + logger, + package_version, + qualname_from_function, + reraise, +) + +try: + import ray # type: ignore[import-not-found] +except ImportError: + raise DidNotEnable("Ray not installed.") +import functools + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any, Optional + from sentry_sdk.utils import ExcInfo + + +def _check_sentry_initialized(): + # type: () -> None + if sentry_sdk.get_client().is_active(): + return + + logger.debug( + "[Tracing] Sentry not initialized in ray cluster worker, performance data will be discarded." + ) + + +def _patch_ray_remote(): + # type: () -> None + old_remote = ray.remote + + @functools.wraps(old_remote) + def new_remote(f, *args, **kwargs): + # type: (Callable[..., Any], *Any, **Any) -> Callable[..., Any] + if inspect.isclass(f): + # Ray Actors + # (https://docs.ray.io/en/latest/ray-core/actors.html) + # are not supported + # (Only Ray Tasks are supported) + return old_remote(f, *args, *kwargs) + + def _f(*f_args, _tracing=None, **f_kwargs): + # type: (Any, Optional[dict[str, Any]], Any) -> Any + """ + Ray Worker + """ + _check_sentry_initialized() + + transaction = sentry_sdk.continue_trace( + _tracing or {}, + op=OP.QUEUE_TASK_RAY, + name=qualname_from_function(f), + origin=RayIntegration.origin, + source=TRANSACTION_SOURCE_TASK, + ) + + with sentry_sdk.start_transaction(transaction) as transaction: + try: + result = f(*f_args, **f_kwargs) + transaction.set_status(SPANSTATUS.OK) + except Exception: + transaction.set_status(SPANSTATUS.INTERNAL_ERROR) + exc_info = sys.exc_info() + _capture_exception(exc_info) + reraise(*exc_info) + + return result + + rv = old_remote(_f, *args, *kwargs) + old_remote_method = rv.remote + + def _remote_method_with_header_propagation(*args, **kwargs): + # type: (*Any, **Any) -> Any + """ + Ray Client + """ + with sentry_sdk.start_span( + op=OP.QUEUE_SUBMIT_RAY, + name=qualname_from_function(f), + origin=RayIntegration.origin, + ) as span: + tracing = { + k: v + for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers() + } + try: + result = old_remote_method(*args, **kwargs, _tracing=tracing) + span.set_status(SPANSTATUS.OK) + except Exception: + span.set_status(SPANSTATUS.INTERNAL_ERROR) + exc_info = sys.exc_info() + _capture_exception(exc_info) + reraise(*exc_info) + + return result + + rv.remote = _remote_method_with_header_propagation + + return rv + + ray.remote = new_remote + + +def _capture_exception(exc_info, **kwargs): + # type: (ExcInfo, **Any) -> None + client = sentry_sdk.get_client() + + event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={ + "handled": False, + "type": RayIntegration.identifier, + }, + ) + sentry_sdk.capture_event(event, hint=hint) + + +class RayIntegration(Integration): + identifier = "ray" + origin = f"auto.queue.{identifier}" + + @staticmethod + def setup_once(): + # type: () -> None + version = package_version("ray") + + if version is None: + raise DidNotEnable("Unparsable ray version: {}".format(version)) + + if version < (2, 7, 0): + raise DidNotEnable("Ray 2.7.0 or newer required") + + _patch_ray_remote() diff --git a/sentry_sdk/integrations/redis/__init__.py b/sentry_sdk/integrations/redis/__init__.py index dded1bdcc0..f443138295 100644 --- a/sentry_sdk/integrations/redis/__init__.py +++ b/sentry_sdk/integrations/redis/__init__.py @@ -1,4 +1,3 @@ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.redis.consts import _DEFAULT_MAX_DATA_SIZE from sentry_sdk.integrations.redis.rb import _patch_rb @@ -7,6 +6,8 @@ from sentry_sdk.integrations.redis.redis_py_cluster_legacy import _patch_rediscluster from sentry_sdk.utils import logger +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/integrations/redis/_async_common.py b/sentry_sdk/integrations/redis/_async_common.py index 50d5ea6c82..196e85e74b 100644 --- a/sentry_sdk/integrations/redis/_async_common.py +++ b/sentry_sdk/integrations/redis/_async_common.py @@ -1,4 +1,4 @@ -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN from sentry_sdk.integrations.redis.modules.caches import ( @@ -12,8 +12,8 @@ ) from sentry_sdk.tracing import Span from sentry_sdk.utils import capture_internal_exceptions -import sentry_sdk +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable @@ -37,7 +37,7 @@ async def _sentry_execute(self, *args, **kwargs): with sentry_sdk.start_span( op=OP.DB_REDIS, - description="redis.pipeline.execute", + name="redis.pipeline.execute", origin=SPAN_ORIGIN, ) as span: with capture_internal_exceptions(): @@ -78,7 +78,7 @@ async def _sentry_execute_command(self, name, *args, **kwargs): if cache_properties["is_cache_key"] and cache_properties["op"] is not None: cache_span = sentry_sdk.start_span( op=cache_properties["op"], - description=cache_properties["description"], + name=cache_properties["description"], origin=SPAN_ORIGIN, ) cache_span.__enter__() @@ -87,7 +87,7 @@ async def _sentry_execute_command(self, name, *args, **kwargs): db_span = sentry_sdk.start_span( op=db_properties["op"], - description=db_properties["description"], + name=db_properties["description"], origin=SPAN_ORIGIN, ) db_span.__enter__() diff --git a/sentry_sdk/integrations/redis/_sync_common.py b/sentry_sdk/integrations/redis/_sync_common.py index 6a01f5e18b..ef10e9e4f0 100644 --- a/sentry_sdk/integrations/redis/_sync_common.py +++ b/sentry_sdk/integrations/redis/_sync_common.py @@ -1,4 +1,4 @@ -from sentry_sdk._types import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import OP from sentry_sdk.integrations.redis.consts import SPAN_ORIGIN from sentry_sdk.integrations.redis.modules.caches import ( @@ -12,8 +12,8 @@ ) from sentry_sdk.tracing import Span from sentry_sdk.utils import capture_internal_exceptions -import sentry_sdk +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable @@ -38,7 +38,7 @@ def sentry_patched_execute(self, *args, **kwargs): with sentry_sdk.start_span( op=OP.DB_REDIS, - description="redis.pipeline.execute", + name="redis.pipeline.execute", origin=SPAN_ORIGIN, ) as span: with capture_internal_exceptions(): @@ -83,7 +83,7 @@ def sentry_patched_execute_command(self, name, *args, **kwargs): if cache_properties["is_cache_key"] and cache_properties["op"] is not None: cache_span = sentry_sdk.start_span( op=cache_properties["op"], - description=cache_properties["description"], + name=cache_properties["description"], origin=SPAN_ORIGIN, ) cache_span.__enter__() @@ -92,7 +92,7 @@ def sentry_patched_execute_command(self, name, *args, **kwargs): db_span = sentry_sdk.start_span( op=db_properties["op"], - description=db_properties["description"], + name=db_properties["description"], origin=SPAN_ORIGIN, ) db_span.__enter__() diff --git a/sentry_sdk/integrations/redis/modules/caches.py b/sentry_sdk/integrations/redis/modules/caches.py index 8d3469d141..c6fc19f5b2 100644 --- a/sentry_sdk/integrations/redis/modules/caches.py +++ b/sentry_sdk/integrations/redis/modules/caches.py @@ -2,7 +2,6 @@ Code used for the Caches module in Sentry """ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.redis.utils import _get_safe_key, _key_as_string from sentry_sdk.utils import capture_internal_exceptions @@ -10,6 +9,8 @@ GET_COMMANDS = ("get", "mget") SET_COMMANDS = ("set", "setex") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.tracing import Span diff --git a/sentry_sdk/integrations/redis/modules/queries.py b/sentry_sdk/integrations/redis/modules/queries.py index 79f82189ae..e0d85a4ef7 100644 --- a/sentry_sdk/integrations/redis/modules/queries.py +++ b/sentry_sdk/integrations/redis/modules/queries.py @@ -2,11 +2,11 @@ Code used for the Queries module in Sentry """ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.redis.utils import _get_safe_command from sentry_sdk.utils import capture_internal_exceptions +from typing import TYPE_CHECKING if TYPE_CHECKING: from redis import Redis diff --git a/sentry_sdk/integrations/redis/redis.py b/sentry_sdk/integrations/redis/redis.py index 8359d0fcbe..c92958a32d 100644 --- a/sentry_sdk/integrations/redis/redis.py +++ b/sentry_sdk/integrations/redis/redis.py @@ -4,13 +4,13 @@ https://github.com/redis/redis-py """ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations.redis._sync_common import ( patch_redis_client, patch_redis_pipeline, ) from sentry_sdk.integrations.redis.modules.queries import _set_db_data +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Sequence diff --git a/sentry_sdk/integrations/redis/redis_cluster.py b/sentry_sdk/integrations/redis/redis_cluster.py index 0f42032e0b..80cdc7235a 100644 --- a/sentry_sdk/integrations/redis/redis_cluster.py +++ b/sentry_sdk/integrations/redis/redis_cluster.py @@ -5,7 +5,6 @@ https://github.com/redis/redis-py/blob/master/redis/cluster.py """ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations.redis._sync_common import ( patch_redis_client, patch_redis_pipeline, @@ -15,6 +14,8 @@ from sentry_sdk.utils import capture_internal_exceptions +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from redis import RedisCluster diff --git a/sentry_sdk/integrations/redis/utils.py b/sentry_sdk/integrations/redis/utils.py index 43ea5b1572..27fae1e8ca 100644 --- a/sentry_sdk/integrations/redis/utils.py +++ b/sentry_sdk/integrations/redis/utils.py @@ -1,4 +1,3 @@ -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import SPANDATA from sentry_sdk.integrations.redis.consts import ( _COMMANDS_INCLUDING_SENSITIVE_DATA, @@ -10,6 +9,7 @@ from sentry_sdk.scope import should_send_default_pii from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Optional, Sequence diff --git a/sentry_sdk/integrations/rq.py b/sentry_sdk/integrations/rq.py index 6afb07c92d..462f3ad30a 100644 --- a/sentry_sdk/integrations/rq.py +++ b/sentry_sdk/integrations/rq.py @@ -23,7 +23,7 @@ except ImportError: raise DidNotEnable("RQ not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any, Callable @@ -90,9 +90,13 @@ def sentry_patched_perform_job(self, job, *args, **kwargs): def sentry_patched_handle_exception(self, job, *exc_info, **kwargs): # type: (Worker, Any, *Any, **Any) -> Any - # Note, the order of the `or` here is important, - # because calling `job.is_failed` will change `_status`. - if job._status == JobStatus.FAILED or job.is_failed: + retry = ( + hasattr(job, "retries_left") + and job.retries_left + and job.retries_left > 0 + ) + failed = job._status == JobStatus.FAILED or job.is_failed + if failed and not retry: _capture_exception(exc_info) return old_handle_exception(self, job, *exc_info, **kwargs) diff --git a/sentry_sdk/integrations/rust_tracing.py b/sentry_sdk/integrations/rust_tracing.py new file mode 100644 index 0000000000..ae52c850c3 --- /dev/null +++ b/sentry_sdk/integrations/rust_tracing.py @@ -0,0 +1,284 @@ +""" +This integration ingests tracing data from native extensions written in Rust. + +Using it requires additional setup on the Rust side to accept a +`RustTracingLayer` Python object and register it with the `tracing-subscriber` +using an adapter from the `pyo3-python-tracing-subscriber` crate. For example: +```rust +#[pyfunction] +pub fn initialize_tracing(py_impl: Bound<'_, PyAny>) { + tracing_subscriber::registry() + .with(pyo3_python_tracing_subscriber::PythonCallbackLayerBridge::new(py_impl)) + .init(); +} +``` + +Usage in Python would then look like: +``` +sentry_sdk.init( + dsn=sentry_dsn, + integrations=[ + RustTracingIntegration( + "demo_rust_extension", + demo_rust_extension.initialize_tracing, + event_type_mapping=event_type_mapping, + ) + ], +) +``` + +Each native extension requires its own integration. +""" + +import json +from enum import Enum, auto +from typing import Any, Callable, Dict, Tuple, Optional + +import sentry_sdk +from sentry_sdk.integrations import Integration +from sentry_sdk.scope import should_send_default_pii +from sentry_sdk.tracing import Span as SentrySpan +from sentry_sdk.utils import SENSITIVE_DATA_SUBSTITUTE + +TraceState = Optional[Tuple[Optional[SentrySpan], SentrySpan]] + + +class RustTracingLevel(Enum): + Trace: str = "TRACE" + Debug: str = "DEBUG" + Info: str = "INFO" + Warn: str = "WARN" + Error: str = "ERROR" + + +class EventTypeMapping(Enum): + Ignore = auto() + Exc = auto() + Breadcrumb = auto() + Event = auto() + + +def tracing_level_to_sentry_level(level): + # type: (str) -> sentry_sdk._types.LogLevelStr + level = RustTracingLevel(level) + if level in (RustTracingLevel.Trace, RustTracingLevel.Debug): + return "debug" + elif level == RustTracingLevel.Info: + return "info" + elif level == RustTracingLevel.Warn: + return "warning" + elif level == RustTracingLevel.Error: + return "error" + else: + # Better this than crashing + return "info" + + +def extract_contexts(event: Dict[str, Any]) -> Dict[str, Any]: + metadata = event.get("metadata", {}) + contexts = {} + + location = {} + for field in ["module_path", "file", "line"]: + if field in metadata: + location[field] = metadata[field] + if len(location) > 0: + contexts["rust_tracing_location"] = location + + fields = {} + for field in metadata.get("fields", []): + fields[field] = event.get(field) + if len(fields) > 0: + contexts["rust_tracing_fields"] = fields + + return contexts + + +def process_event(event: Dict[str, Any]) -> None: + metadata = event.get("metadata", {}) + + logger = metadata.get("target") + level = tracing_level_to_sentry_level(metadata.get("level")) + message = event.get("message") # type: sentry_sdk._types.Any + contexts = extract_contexts(event) + + sentry_event = { + "logger": logger, + "level": level, + "message": message, + "contexts": contexts, + } # type: sentry_sdk._types.Event + + sentry_sdk.capture_event(sentry_event) + + +def process_exception(event: Dict[str, Any]) -> None: + process_event(event) + + +def process_breadcrumb(event: Dict[str, Any]) -> None: + level = tracing_level_to_sentry_level(event.get("metadata", {}).get("level")) + message = event.get("message") + + sentry_sdk.add_breadcrumb(level=level, message=message) + + +def default_span_filter(metadata: Dict[str, Any]) -> bool: + return RustTracingLevel(metadata.get("level")) in ( + RustTracingLevel.Error, + RustTracingLevel.Warn, + RustTracingLevel.Info, + ) + + +def default_event_type_mapping(metadata: Dict[str, Any]) -> EventTypeMapping: + level = RustTracingLevel(metadata.get("level")) + if level == RustTracingLevel.Error: + return EventTypeMapping.Exc + elif level in (RustTracingLevel.Warn, RustTracingLevel.Info): + return EventTypeMapping.Breadcrumb + elif level in (RustTracingLevel.Debug, RustTracingLevel.Trace): + return EventTypeMapping.Ignore + else: + return EventTypeMapping.Ignore + + +class RustTracingLayer: + def __init__( + self, + origin: str, + event_type_mapping: Callable[ + [Dict[str, Any]], EventTypeMapping + ] = default_event_type_mapping, + span_filter: Callable[[Dict[str, Any]], bool] = default_span_filter, + include_tracing_fields: Optional[bool] = None, + ): + self.origin = origin + self.event_type_mapping = event_type_mapping + self.span_filter = span_filter + self.include_tracing_fields = include_tracing_fields + + def _include_tracing_fields(self) -> bool: + """ + By default, the values of tracing fields are not included in case they + contain PII. A user may override that by passing `True` for the + `include_tracing_fields` keyword argument of this integration or by + setting `send_default_pii` to `True` in their Sentry client options. + """ + return ( + should_send_default_pii() + if self.include_tracing_fields is None + else self.include_tracing_fields + ) + + def on_event(self, event: str, _span_state: TraceState) -> None: + deserialized_event = json.loads(event) + metadata = deserialized_event.get("metadata", {}) + + event_type = self.event_type_mapping(metadata) + if event_type == EventTypeMapping.Ignore: + return + elif event_type == EventTypeMapping.Exc: + process_exception(deserialized_event) + elif event_type == EventTypeMapping.Breadcrumb: + process_breadcrumb(deserialized_event) + elif event_type == EventTypeMapping.Event: + process_event(deserialized_event) + + def on_new_span(self, attrs: str, span_id: str) -> TraceState: + attrs = json.loads(attrs) + metadata = attrs.get("metadata", {}) + + if not self.span_filter(metadata): + return None + + module_path = metadata.get("module_path") + name = metadata.get("name") + message = attrs.get("message") + + if message is not None: + sentry_span_name = message + elif module_path is not None and name is not None: + sentry_span_name = f"{module_path}::{name}" # noqa: E231 + elif name is not None: + sentry_span_name = name + else: + sentry_span_name = "" + + kwargs = { + "op": "function", + "name": sentry_span_name, + "origin": self.origin, + } + + scope = sentry_sdk.get_current_scope() + parent_sentry_span = scope.span + if parent_sentry_span: + sentry_span = parent_sentry_span.start_child(**kwargs) + else: + sentry_span = scope.start_span(**kwargs) + + fields = metadata.get("fields", []) + for field in fields: + if self._include_tracing_fields(): + sentry_span.set_data(field, attrs.get(field)) + else: + sentry_span.set_data(field, SENSITIVE_DATA_SUBSTITUTE) + + scope.span = sentry_span + return (parent_sentry_span, sentry_span) + + def on_close(self, span_id: str, span_state: TraceState) -> None: + if span_state is None: + return + + parent_sentry_span, sentry_span = span_state + sentry_span.finish() + sentry_sdk.get_current_scope().span = parent_sentry_span + + def on_record(self, span_id: str, values: str, span_state: TraceState) -> None: + if span_state is None: + return + _parent_sentry_span, sentry_span = span_state + + deserialized_values = json.loads(values) + for key, value in deserialized_values.items(): + if self._include_tracing_fields(): + sentry_span.set_data(key, value) + else: + sentry_span.set_data(key, SENSITIVE_DATA_SUBSTITUTE) + + +class RustTracingIntegration(Integration): + """ + Ingests tracing data from a Rust native extension's `tracing` instrumentation. + + If a project uses more than one Rust native extension, each one will need + its own instance of `RustTracingIntegration` with an initializer function + specific to that extension. + + Since all of the setup for this integration requires instance-specific state + which is not available in `setup_once()`, setup instead happens in `__init__()`. + """ + + def __init__( + self, + identifier: str, + initializer: Callable[[RustTracingLayer], None], + event_type_mapping: Callable[ + [Dict[str, Any]], EventTypeMapping + ] = default_event_type_mapping, + span_filter: Callable[[Dict[str, Any]], bool] = default_span_filter, + include_tracing_fields: Optional[bool] = None, + ): + self.identifier = identifier + origin = f"auto.function.rust_tracing.{identifier}" + self.tracing_layer = RustTracingLayer( + origin, event_type_mapping, span_filter, include_tracing_fields + ) + + initializer(self.tracing_layer) + + @staticmethod + def setup_once() -> None: + pass diff --git a/sentry_sdk/integrations/sanic.py b/sentry_sdk/integrations/sanic.py index 36e3b4c892..26e29cb78c 100644 --- a/sentry_sdk/integrations/sanic.py +++ b/sentry_sdk/integrations/sanic.py @@ -19,7 +19,8 @@ parse_version, reraise, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Container @@ -211,9 +212,7 @@ async def _context_exit(request, response=None): if not request.ctx._sentry_do_integration: return - integration = sentry_sdk.get_client().get_integration( - SanicIntegration - ) # type: Integration + integration = sentry_sdk.get_client().get_integration(SanicIntegration) response_status = None if response is None else response.status diff --git a/sentry_sdk/integrations/serverless.py b/sentry_sdk/integrations/serverless.py index a8fbc826fd..760c07ffad 100644 --- a/sentry_sdk/integrations/serverless.py +++ b/sentry_sdk/integrations/serverless.py @@ -3,7 +3,8 @@ import sentry_sdk from sentry_sdk.utils import event_from_exception, reraise -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -11,7 +12,6 @@ from typing import TypeVar from typing import Union from typing import Optional - from typing import overload F = TypeVar("F", bound=Callable[..., Any]) diff --git a/sentry_sdk/integrations/socket.py b/sentry_sdk/integrations/socket.py index beec7dbf3e..0866ceb608 100644 --- a/sentry_sdk/integrations/socket.py +++ b/sentry_sdk/integrations/socket.py @@ -55,7 +55,7 @@ def create_connection( with sentry_sdk.start_span( op=OP.SOCKET_CONNECTION, - description=_get_span_description(address[0], address[1]), + name=_get_span_description(address[0], address[1]), origin=SocketIntegration.origin, ) as span: span.set_data("address", address) @@ -81,7 +81,7 @@ def getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): with sentry_sdk.start_span( op=OP.SOCKET_DNS, - description=_get_span_description(host, port), + name=_get_span_description(host, port), origin=SocketIntegration.origin, ) as span: span.set_data("host", host) diff --git a/sentry_sdk/integrations/spark/spark_driver.py b/sentry_sdk/integrations/spark/spark_driver.py index b55550cbef..a86f16344d 100644 --- a/sentry_sdk/integrations/spark/spark_driver.py +++ b/sentry_sdk/integrations/spark/spark_driver.py @@ -2,13 +2,14 @@ from sentry_sdk.integrations import Integration from sentry_sdk.utils import capture_internal_exceptions, ensure_integration_enabled -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Optional from sentry_sdk._types import Event, Hint + from pyspark import SparkContext class SparkIntegration(Integration): @@ -17,7 +18,7 @@ class SparkIntegration(Integration): @staticmethod def setup_once(): # type: () -> None - patch_spark_context_init() + _setup_sentry_tracing() def _set_app_properties(): @@ -37,7 +38,7 @@ def _set_app_properties(): def _start_sentry_listener(sc): - # type: (Any) -> None + # type: (SparkContext) -> None """ Start java gateway server to add custom `SparkListener` """ @@ -49,7 +50,51 @@ def _start_sentry_listener(sc): sc._jsc.sc().addSparkListener(listener) -def patch_spark_context_init(): +def _add_event_processor(sc): + # type: (SparkContext) -> None + scope = sentry_sdk.get_isolation_scope() + + @scope.add_event_processor + def process_event(event, hint): + # type: (Event, Hint) -> Optional[Event] + with capture_internal_exceptions(): + if sentry_sdk.get_client().get_integration(SparkIntegration) is None: + return event + + if sc._active_spark_context is None: + return event + + event.setdefault("user", {}).setdefault("id", sc.sparkUser()) + + event.setdefault("tags", {}).setdefault( + "executor.id", sc._conf.get("spark.executor.id") + ) + event["tags"].setdefault( + "spark-submit.deployMode", + sc._conf.get("spark.submit.deployMode"), + ) + event["tags"].setdefault("driver.host", sc._conf.get("spark.driver.host")) + event["tags"].setdefault("driver.port", sc._conf.get("spark.driver.port")) + event["tags"].setdefault("spark_version", sc.version) + event["tags"].setdefault("app_name", sc.appName) + event["tags"].setdefault("application_id", sc.applicationId) + event["tags"].setdefault("master", sc.master) + event["tags"].setdefault("spark_home", sc.sparkHome) + + event.setdefault("extra", {}).setdefault("web_url", sc.uiWebUrl) + + return event + + +def _activate_integration(sc): + # type: (SparkContext) -> None + + _start_sentry_listener(sc) + _set_app_properties() + _add_event_processor(sc) + + +def _patch_spark_context_init(): # type: () -> None from pyspark import SparkContext @@ -59,51 +104,22 @@ def patch_spark_context_init(): def _sentry_patched_spark_context_init(self, *args, **kwargs): # type: (SparkContext, *Any, **Any) -> Optional[Any] rv = spark_context_init(self, *args, **kwargs) - _start_sentry_listener(self) - _set_app_properties() - - scope = sentry_sdk.get_isolation_scope() - - @scope.add_event_processor - def process_event(event, hint): - # type: (Event, Hint) -> Optional[Event] - with capture_internal_exceptions(): - if sentry_sdk.get_client().get_integration(SparkIntegration) is None: - return event - - if self._active_spark_context is None: - return event - - event.setdefault("user", {}).setdefault("id", self.sparkUser()) - - event.setdefault("tags", {}).setdefault( - "executor.id", self._conf.get("spark.executor.id") - ) - event["tags"].setdefault( - "spark-submit.deployMode", - self._conf.get("spark.submit.deployMode"), - ) - event["tags"].setdefault( - "driver.host", self._conf.get("spark.driver.host") - ) - event["tags"].setdefault( - "driver.port", self._conf.get("spark.driver.port") - ) - event["tags"].setdefault("spark_version", self.version) - event["tags"].setdefault("app_name", self.appName) - event["tags"].setdefault("application_id", self.applicationId) - event["tags"].setdefault("master", self.master) - event["tags"].setdefault("spark_home", self.sparkHome) - - event.setdefault("extra", {}).setdefault("web_url", self.uiWebUrl) - - return event - + _activate_integration(self) return rv SparkContext._do_init = _sentry_patched_spark_context_init +def _setup_sentry_tracing(): + # type: () -> None + from pyspark import SparkContext + + if SparkContext._active_spark_context is not None: + _activate_integration(SparkContext._active_spark_context) + return + _patch_spark_context_init() + + class SparkListener: def onApplicationEnd(self, applicationEnd): # noqa: N802,N803 # type: (Any) -> None @@ -208,10 +224,21 @@ class Java: class SentryListener(SparkListener): + def _add_breadcrumb( + self, + level, # type: str + message, # type: str + data=None, # type: Optional[dict[str, Any]] + ): + # type: (...) -> None + sentry_sdk.get_global_scope().add_breadcrumb( + level=level, message=message, data=data + ) + def onJobStart(self, jobStart): # noqa: N802,N803 # type: (Any) -> None message = "Job {} Started".format(jobStart.jobId()) - sentry_sdk.add_breadcrumb(level="info", message=message) + self._add_breadcrumb(level="info", message=message) _set_app_properties() def onJobEnd(self, jobEnd): # noqa: N802,N803 @@ -227,14 +254,14 @@ def onJobEnd(self, jobEnd): # noqa: N802,N803 level = "warning" message = "Job {} Failed".format(jobEnd.jobId()) - sentry_sdk.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) def onStageSubmitted(self, stageSubmitted): # noqa: N802,N803 # type: (Any) -> None stage_info = stageSubmitted.stageInfo() message = "Stage {} Submitted".format(stage_info.stageId()) data = {"attemptId": stage_info.attemptId(), "name": stage_info.name()} - sentry_sdk.add_breadcrumb(level="info", message=message, data=data) + self._add_breadcrumb(level="info", message=message, data=data) _set_app_properties() def onStageCompleted(self, stageCompleted): # noqa: N802,N803 @@ -255,4 +282,4 @@ def onStageCompleted(self, stageCompleted): # noqa: N802,N803 message = "Stage {} Completed".format(stage_info.stageId()) level = "info" - sentry_sdk.add_breadcrumb(level=level, message=message, data=data) + self._add_breadcrumb(level=level, message=message, data=data) diff --git a/sentry_sdk/integrations/spark/spark_worker.py b/sentry_sdk/integrations/spark/spark_worker.py index d9e598603e..5340a0b350 100644 --- a/sentry_sdk/integrations/spark/spark_worker.py +++ b/sentry_sdk/integrations/spark/spark_worker.py @@ -10,7 +10,7 @@ event_hint_with_exc_info, ) -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/sqlalchemy.py b/sentry_sdk/integrations/sqlalchemy.py index bcb06e3330..0a54108e75 100644 --- a/sentry_sdk/integrations/sqlalchemy.py +++ b/sentry_sdk/integrations/sqlalchemy.py @@ -1,7 +1,4 @@ -import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import SPANSTATUS, SPANDATA -from sentry_sdk.db.explain_plan.sqlalchemy import attach_explain_plan_to_span from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing_utils import add_query_source, record_sql_queries from sentry_sdk.utils import ( @@ -17,6 +14,8 @@ except ImportError: raise DidNotEnable("SQLAlchemy not installed.") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import ContextManager @@ -67,17 +66,6 @@ def _before_cursor_execute( if span is not None: _set_db_data(span, conn) - options = ( - sentry_sdk.get_client().options["_experiments"].get("attach_explain_plans") - ) - if options is not None: - attach_explain_plan_to_span( - span, - conn, - statement, - parameters, - options, - ) context._sentry_sql_span = span diff --git a/sentry_sdk/integrations/starlette.py b/sentry_sdk/integrations/starlette.py index 3b7aa11a93..d9db8bd6b8 100644 --- a/sentry_sdk/integrations/starlette.py +++ b/sentry_sdk/integrations/starlette.py @@ -1,13 +1,19 @@ import asyncio import functools +import warnings +from collections.abc import Set from copy import deepcopy import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP -from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations import ( + DidNotEnable, + Integration, + _DEFAULT_FAILED_REQUEST_STATUS_CODES, +) from sentry_sdk.integrations._wsgi_common import ( - _in_http_status_code_range, + DEFAULT_HTTP_METHODS_TO_CAPTURE, + HttpCodeRangeContainer, _is_json_content_type, request_body_within_bounds, ) @@ -28,8 +34,10 @@ transaction_from_function, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: - from typing import Any, Awaitable, Callable, Dict, Optional, Tuple + from typing import Any, Awaitable, Callable, Container, Dict, Optional, Tuple, Union from sentry_sdk._types import Event, HttpStatusCodeRange @@ -57,7 +65,12 @@ try: # Optional dependency of Starlette to parse form data. - import multipart # type: ignore + try: + # python-multipart 0.0.13 and later + import python_multipart as multipart # type: ignore + except ImportError: + # python-multipart 0.0.12 and earlier + import multipart # type: ignore except ImportError: multipart = None @@ -75,11 +88,12 @@ class StarletteIntegration(Integration): def __init__( self, - transaction_style="url", - failed_request_status_codes=None, - middleware_spans=True, + transaction_style="url", # type: str + failed_request_status_codes=_DEFAULT_FAILED_REQUEST_STATUS_CODES, # type: Union[Set[int], list[HttpStatusCodeRange], None] + middleware_spans=True, # type: bool + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: tuple[str, ...] ): - # type: (str, Optional[list[HttpStatusCodeRange]], bool) -> None + # type: (...) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -87,9 +101,26 @@ def __init__( ) self.transaction_style = transaction_style self.middleware_spans = middleware_spans - self.failed_request_status_codes = failed_request_status_codes or [ - range(500, 599) - ] + self.http_methods_to_capture = tuple(map(str.upper, http_methods_to_capture)) + + if isinstance(failed_request_status_codes, Set): + self.failed_request_status_codes = ( + failed_request_status_codes + ) # type: Container[int] + else: + warnings.warn( + "Passing a list or None for failed_request_status_codes is deprecated. " + "Please pass a set of int instead.", + DeprecationWarning, + stacklevel=2, + ) + + if failed_request_status_codes is None: + self.failed_request_status_codes = _DEFAULT_FAILED_REQUEST_STATUS_CODES + else: + self.failed_request_status_codes = HttpCodeRangeContainer( + failed_request_status_codes + ) @staticmethod def setup_once(): @@ -131,7 +162,7 @@ async def _create_span_call(app, scope, receive, send, **kwargs): with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE, - description=middleware_name, + name=middleware_name, origin=StarletteIntegration.origin, ) as middleware_span: middleware_span.set_tag("starlette.middleware_name", middleware_name) @@ -141,7 +172,7 @@ async def _sentry_receive(*args, **kwargs): # type: (*Any, **Any) -> Any with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE_RECEIVE, - description=getattr(receive, "__qualname__", str(receive)), + name=getattr(receive, "__qualname__", str(receive)), origin=StarletteIntegration.origin, ) as span: span.set_tag("starlette.middleware_name", middleware_name) @@ -156,7 +187,7 @@ async def _sentry_send(*args, **kwargs): # type: (*Any, **Any) -> Any with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLETTE_SEND, - description=getattr(send, "__qualname__", str(send)), + name=getattr(send, "__qualname__", str(send)), origin=StarletteIntegration.origin, ) as span: span.set_tag("starlette.middleware_name", middleware_name) @@ -219,15 +250,14 @@ async def _sentry_patched_exception_handler(self, *args, **kwargs): exp = args[0] - is_http_server_error = ( - hasattr(exp, "status_code") - and isinstance(exp.status_code, int) - and _in_http_status_code_range( - exp.status_code, integration.failed_request_status_codes + if integration is not None: + is_http_server_error = ( + hasattr(exp, "status_code") + and isinstance(exp.status_code, int) + and exp.status_code in integration.failed_request_status_codes ) - ) - if is_http_server_error: - _capture_exception(exp, handled=True) + if is_http_server_error: + _capture_exception(exp, handled=True) # Find a matching handler old_handler = None @@ -368,6 +398,11 @@ async def _sentry_patched_asgi_app(self, scope, receive, send): mechanism_type=StarletteIntegration.identifier, transaction_style=integration.transaction_style, span_origin=StarletteIntegration.origin, + http_methods_to_capture=( + integration.http_methods_to_capture + if integration + else DEFAULT_HTTP_METHODS_TO_CAPTURE + ), ) middleware.__call__ = middleware._run_asgi3 @@ -448,14 +483,20 @@ def event_processor(event, hint): else: - @ensure_integration_enabled(StarletteIntegration, old_func) + @functools.wraps(old_func) def _sentry_sync_func(*args, **kwargs): # type: (*Any, **Any) -> Any integration = sentry_sdk.get_client().get_integration( StarletteIntegration ) - sentry_scope = sentry_sdk.get_isolation_scope() + if integration is None: + return old_func(*args, **kwargs) + + current_scope = sentry_sdk.get_current_scope() + if current_scope.transaction is not None: + current_scope.transaction.update_active_thread() + sentry_scope = sentry_sdk.get_isolation_scope() if sentry_scope.profile is not None: sentry_scope.profile.update_active_thread_id() diff --git a/sentry_sdk/integrations/starlite.py b/sentry_sdk/integrations/starlite.py index 8e72751e95..8714ee2f08 100644 --- a/sentry_sdk/integrations/starlite.py +++ b/sentry_sdk/integrations/starlite.py @@ -1,5 +1,4 @@ import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable, Integration from sentry_sdk.integrations.asgi import SentryAsgiMiddleware @@ -22,6 +21,8 @@ except ImportError: raise DidNotEnable("Starlite is not installed") +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any, Optional, Union from starlite.types import ( # type: ignore @@ -137,7 +138,7 @@ async def _create_span_call(self, scope, receive, send): middleware_name = self.__class__.__name__ with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLITE, - description=middleware_name, + name=middleware_name, origin=StarliteIntegration.origin, ) as middleware_span: middleware_span.set_tag("starlite.middleware_name", middleware_name) @@ -149,7 +150,7 @@ async def _sentry_receive(*args, **kwargs): return await receive(*args, **kwargs) with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLITE_RECEIVE, - description=getattr(receive, "__qualname__", str(receive)), + name=getattr(receive, "__qualname__", str(receive)), origin=StarliteIntegration.origin, ) as span: span.set_tag("starlite.middleware_name", middleware_name) @@ -166,7 +167,7 @@ async def _sentry_send(message): return await send(message) with sentry_sdk.start_span( op=OP.MIDDLEWARE_STARLITE_SEND, - description=getattr(send, "__qualname__", str(send)), + name=getattr(send, "__qualname__", str(send)), origin=StarliteIntegration.origin, ) as span: span.set_tag("starlite.middleware_name", middleware_name) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index ad8e965a4a..d388c5bca6 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -18,7 +18,8 @@ safe_repr, parse_url, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -89,7 +90,7 @@ def putrequest(self, method, url, *args, **kwargs): span = sentry_sdk.start_span( op=OP.HTTP_CLIENT, - description="%s %s" + name="%s %s" % (method, parsed_url.url if parsed_url else SENSITIVE_DATA_SUBSTITUTE), origin="auto.http.stdlib.httplib", ) @@ -126,11 +127,13 @@ def getresponse(self, *args, **kwargs): if span is None: return real_getresponse(self, *args, **kwargs) - rv = real_getresponse(self, *args, **kwargs) + try: + rv = real_getresponse(self, *args, **kwargs) - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) - span.finish() + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) + finally: + span.finish() return rv @@ -202,7 +205,7 @@ def sentry_patched_popen_init(self, *a, **kw): with sentry_sdk.start_span( op=OP.SUBPROCESS, - description=description, + name=description, origin="auto.subprocess.stdlib.subprocess", ) as span: for k, v in sentry_sdk.get_current_scope().iter_trace_propagation_headers( diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 148edac334..58860a633b 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -1,3 +1,4 @@ +import functools import hashlib from inspect import isawaitable @@ -15,7 +16,6 @@ package_version, _get_installed_modules, ) -from sentry_sdk._types import TYPE_CHECKING try: from functools import cached_property @@ -31,19 +31,26 @@ from strawberry import Schema from strawberry.extensions import SchemaExtension # type: ignore from strawberry.extensions.tracing.utils import should_skip_tracing as strawberry_should_skip_tracing # type: ignore + from strawberry.http import async_base_view, sync_base_view # type: ignore +except ImportError: + raise DidNotEnable("strawberry-graphql is not installed") + +try: from strawberry.extensions.tracing import ( # type: ignore SentryTracingExtension as StrawberrySentryAsyncExtension, SentryTracingExtensionSync as StrawberrySentrySyncExtension, ) - from strawberry.http import async_base_view, sync_base_view # type: ignore except ImportError: - raise DidNotEnable("strawberry-graphql is not installed") + StrawberrySentryAsyncExtension = None + StrawberrySentrySyncExtension = None + +from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Callable, Generator, List, Optional + from typing import Any, Callable, Generator, List, Optional, Union from graphql import GraphQLError, GraphQLResolveInfo # type: ignore from strawberry.http import GraphQLHTTPResponse - from strawberry.types import ExecutionContext, ExecutionResult # type: ignore + from strawberry.types import ExecutionContext, ExecutionResult, SubscriptionExecutionResult # type: ignore from sentry_sdk._types import Event, EventProcessor @@ -86,10 +93,13 @@ def _patch_schema_init(): # type: () -> None old_schema_init = Schema.__init__ - @ensure_integration_enabled(StrawberryIntegration, old_schema_init) + @functools.wraps(old_schema_init) def _sentry_patched_schema_init(self, *args, **kwargs): # type: (Schema, Any, Any) -> None integration = sentry_sdk.get_client().get_integration(StrawberryIntegration) + if integration is None: + return old_schema_init(self, *args, **kwargs) + extensions = kwargs.get("extensions") or [] if integration.async_execution is not None: @@ -181,13 +191,13 @@ def on_operation(self): if span: self.graphql_span = span.start_child( op=op, - description=description, + name=description, origin=StrawberryIntegration.origin, ) else: self.graphql_span = sentry_sdk.start_span( op=op, - description=description, + name=description, origin=StrawberryIntegration.origin, ) @@ -210,7 +220,7 @@ def on_validate(self): # type: () -> Generator[None, None, None] self.validation_span = self.graphql_span.start_child( op=OP.GRAPHQL_VALIDATE, - description="validation", + name="validation", origin=StrawberryIntegration.origin, ) @@ -222,7 +232,7 @@ def on_parse(self): # type: () -> Generator[None, None, None] self.parsing_span = self.graphql_span.start_child( op=OP.GRAPHQL_PARSE, - description="parsing", + name="parsing", origin=StrawberryIntegration.origin, ) @@ -252,7 +262,7 @@ async def resolve(self, _next, root, info, *args, **kwargs): with self.graphql_span.start_child( op=OP.GRAPHQL_RESOLVE, - description="resolving {}".format(field_path), + name="resolving {}".format(field_path), origin=StrawberryIntegration.origin, ) as span: span.set_data("graphql.field_name", info.field_name) @@ -273,7 +283,7 @@ def resolve(self, _next, root, info, *args, **kwargs): with self.graphql_span.start_child( op=OP.GRAPHQL_RESOLVE, - description="resolving {}".format(field_path), + name="resolving {}".format(field_path), origin=StrawberryIntegration.origin, ) as span: span.set_data("graphql.field_name", info.field_name) @@ -290,13 +300,13 @@ def _patch_execute(): old_execute_sync = strawberry_schema.execute_sync async def _sentry_patched_execute_async(*args, **kwargs): - # type: (Any, Any) -> ExecutionResult + # type: (Any, Any) -> Union[ExecutionResult, SubscriptionExecutionResult] result = await old_execute_async(*args, **kwargs) if sentry_sdk.get_client().get_integration(StrawberryIntegration) is None: return result - if "execution_context" in kwargs and result.errors: + if "execution_context" in kwargs: scope = sentry_sdk.get_isolation_scope() event_processor = _make_request_event_processor(kwargs["execution_context"]) scope.add_event_processor(event_processor) @@ -308,7 +318,7 @@ def _sentry_patched_execute_sync(*args, **kwargs): # type: (Any, Any) -> ExecutionResult result = old_execute_sync(*args, **kwargs) - if "execution_context" in kwargs and result.errors: + if "execution_context" in kwargs: scope = sentry_sdk.get_isolation_scope() event_processor = _make_request_event_processor(kwargs["execution_context"]) scope.add_event_processor(event_processor) diff --git a/sentry_sdk/integrations/sys_exit.py b/sentry_sdk/integrations/sys_exit.py new file mode 100644 index 0000000000..2341e11359 --- /dev/null +++ b/sentry_sdk/integrations/sys_exit.py @@ -0,0 +1,70 @@ +import functools +import sys + +import sentry_sdk +from sentry_sdk.utils import capture_internal_exceptions, event_from_exception +from sentry_sdk.integrations import Integration +from sentry_sdk._types import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import NoReturn, Union + + +class SysExitIntegration(Integration): + """Captures sys.exit calls and sends them as events to Sentry. + + By default, SystemExit exceptions are not captured by the SDK. Enabling this integration will capture SystemExit + exceptions generated by sys.exit calls and send them to Sentry. + + This integration, in its default configuration, only captures the sys.exit call if the exit code is a non-zero and + non-None value (unsuccessful exits). Pass `capture_successful_exits=True` to capture successful exits as well. + Note that the integration does not capture SystemExit exceptions raised outside a call to sys.exit. + """ + + identifier = "sys_exit" + + def __init__(self, *, capture_successful_exits=False): + # type: (bool) -> None + self._capture_successful_exits = capture_successful_exits + + @staticmethod + def setup_once(): + # type: () -> None + SysExitIntegration._patch_sys_exit() + + @staticmethod + def _patch_sys_exit(): + # type: () -> None + old_exit = sys.exit # type: Callable[[Union[str, int, None]], NoReturn] + + @functools.wraps(old_exit) + def sentry_patched_exit(__status=0): + # type: (Union[str, int, None]) -> NoReturn + # @ensure_integration_enabled ensures that this is non-None + integration = sentry_sdk.get_client().get_integration(SysExitIntegration) + if integration is None: + old_exit(__status) + + try: + old_exit(__status) + except SystemExit as e: + with capture_internal_exceptions(): + if integration._capture_successful_exits or __status not in ( + 0, + None, + ): + _capture_exception(e) + raise e + + sys.exit = sentry_patched_exit + + +def _capture_exception(exc): + # type: (SystemExit) -> None + event, hint = event_from_exception( + exc, + client_options=sentry_sdk.get_client().options, + mechanism={"type": SysExitIntegration.identifier, "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/threading.py b/sentry_sdk/integrations/threading.py index 6dd6acbae1..5de736e23b 100644 --- a/sentry_sdk/integrations/threading.py +++ b/sentry_sdk/integrations/threading.py @@ -3,17 +3,17 @@ from threading import Thread, current_thread import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.integrations import Integration from sentry_sdk.scope import use_isolation_scope, use_scope from sentry_sdk.utils import ( - ensure_integration_enabled, event_from_exception, capture_internal_exceptions, logger, reraise, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import TypeVar @@ -50,10 +50,12 @@ def setup_once(): old_start = Thread.start @wraps(old_start) - @ensure_integration_enabled(ThreadingIntegration, old_start) def sentry_start(self, *a, **kw): # type: (Thread, *Any, **Any) -> Any integration = sentry_sdk.get_client().get_integration(ThreadingIntegration) + if integration is None: + return old_start(self, *a, **kw) + if integration.propagate_scope: isolation_scope = sentry_sdk.get_isolation_scope() current_scope = sentry_sdk.get_current_scope() diff --git a/sentry_sdk/integrations/tornado.py b/sentry_sdk/integrations/tornado.py index c459ee8922..f1bd196261 100644 --- a/sentry_sdk/integrations/tornado.py +++ b/sentry_sdk/integrations/tornado.py @@ -33,7 +33,7 @@ except ImportError: raise DidNotEnable("Tornado not installed") -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/sentry_sdk/integrations/typer.py b/sentry_sdk/integrations/typer.py new file mode 100644 index 0000000000..8879d6d0d0 --- /dev/null +++ b/sentry_sdk/integrations/typer.py @@ -0,0 +1,60 @@ +import sentry_sdk +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, +) +from sentry_sdk.integrations import Integration, DidNotEnable + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Callable + from typing import Any + from typing import Type + from typing import Optional + + from types import TracebackType + + Excepthook = Callable[ + [Type[BaseException], BaseException, Optional[TracebackType]], + Any, + ] + +try: + import typer +except ImportError: + raise DidNotEnable("Typer not installed") + + +class TyperIntegration(Integration): + identifier = "typer" + + @staticmethod + def setup_once(): + # type: () -> None + typer.main.except_hook = _make_excepthook(typer.main.except_hook) # type: ignore + + +def _make_excepthook(old_excepthook): + # type: (Excepthook) -> Excepthook + def sentry_sdk_excepthook(type_, value, traceback): + # type: (Type[BaseException], BaseException, Optional[TracebackType]) -> None + integration = sentry_sdk.get_client().get_integration(TyperIntegration) + + # Note: If we replace this with ensure_integration_enabled then + # we break the exceptiongroup backport; + # See: https://github.com/getsentry/sentry-python/issues/3097 + if integration is None: + return old_excepthook(type_, value, traceback) + + with capture_internal_exceptions(): + event, hint = event_from_exception( + (type_, value, traceback), + client_options=sentry_sdk.get_client().options, + mechanism={"type": "typer", "handled": False}, + ) + sentry_sdk.capture_event(event, hint=hint) + + return old_excepthook(type_, value, traceback) + + return sentry_sdk_excepthook diff --git a/sentry_sdk/integrations/wsgi.py b/sentry_sdk/integrations/wsgi.py index 1b5c9c7c43..50deae10c5 100644 --- a/sentry_sdk/integrations/wsgi.py +++ b/sentry_sdk/integrations/wsgi.py @@ -2,15 +2,16 @@ from functools import partial import sentry_sdk -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk._werkzeug import get_host, _get_headers from sentry_sdk.api import continue_trace from sentry_sdk.consts import OP from sentry_sdk.scope import should_send_default_pii -from sentry_sdk.integrations._wsgi_common import _filter_headers -from sentry_sdk.sessions import ( - auto_session_tracking_scope as auto_session_tracking, -) # When the Hub is removed, this should be renamed (see comment in sentry_sdk/sessions.py) +from sentry_sdk.integrations._wsgi_common import ( + DEFAULT_HTTP_METHODS_TO_CAPTURE, + _filter_headers, + nullcontext, +) +from sentry_sdk.sessions import track_session from sentry_sdk.scope import use_isolation_scope from sentry_sdk.tracing import Transaction, TRANSACTION_SOURCE_ROUTE from sentry_sdk.utils import ( @@ -20,6 +21,8 @@ reraise, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Callable from typing import Dict @@ -67,13 +70,25 @@ def get_request_url(environ, use_x_forwarded_for=False): class SentryWsgiMiddleware: - __slots__ = ("app", "use_x_forwarded_for", "span_origin") + __slots__ = ( + "app", + "use_x_forwarded_for", + "span_origin", + "http_methods_to_capture", + ) - def __init__(self, app, use_x_forwarded_for=False, span_origin="manual"): - # type: (Callable[[Dict[str, str], Callable[..., Any]], Any], bool, str) -> None + def __init__( + self, + app, # type: Callable[[Dict[str, str], Callable[..., Any]], Any] + use_x_forwarded_for=False, # type: bool + span_origin="manual", # type: str + http_methods_to_capture=DEFAULT_HTTP_METHODS_TO_CAPTURE, # type: Tuple[str, ...] + ): + # type: (...) -> None self.app = app self.use_x_forwarded_for = use_x_forwarded_for self.span_origin = span_origin + self.http_methods_to_capture = http_methods_to_capture def __call__(self, environ, start_response): # type: (Dict[str, str], Callable[..., Any]) -> _ScopedResponse @@ -83,7 +98,7 @@ def __call__(self, environ, start_response): _wsgi_middleware_applied.set(True) try: with sentry_sdk.isolation_scope() as scope: - with auto_session_tracking(scope, session_mode="request"): + with track_session(scope, session_mode="request"): with capture_internal_exceptions(): scope.clear_breadcrumbs() scope._name = "wsgi" @@ -93,16 +108,24 @@ def __call__(self, environ, start_response): ) ) - transaction = continue_trace( - environ, - op=OP.HTTP_SERVER, - name="generic WSGI request", - source=TRANSACTION_SOURCE_ROUTE, - origin=self.span_origin, - ) + method = environ.get("REQUEST_METHOD", "").upper() + transaction = None + if method in self.http_methods_to_capture: + transaction = continue_trace( + environ, + op=OP.HTTP_SERVER, + name="generic WSGI request", + source=TRANSACTION_SOURCE_ROUTE, + origin=self.span_origin, + ) - with sentry_sdk.start_transaction( - transaction, custom_sampling_context={"wsgi_environ": environ} + with ( + sentry_sdk.start_transaction( + transaction, + custom_sampling_context={"wsgi_environ": environ}, + ) + if transaction is not None + else nullcontext() ): try: response = self.app( @@ -121,7 +144,7 @@ def __call__(self, environ, start_response): def _sentry_start_response( # type: ignore old_start_response, # type: StartResponse - transaction, # type: Transaction + transaction, # type: Optional[Transaction] status, # type: str response_headers, # type: WsgiResponseHeaders exc_info=None, # type: Optional[WsgiExcInfo] @@ -129,7 +152,8 @@ def _sentry_start_response( # type: ignore # type: (...) -> WsgiResponseIter with capture_internal_exceptions(): status_int = int(status.split(" ", 1)[0]) - transaction.set_http_status(status_int) + if transaction is not None: + transaction.set_http_status(status_int) if exc_info is None: # The Django Rest Framework WSGI test client, and likely other diff --git a/sentry_sdk/metrics.py b/sentry_sdk/metrics.py index 452bb61658..f6e9fd6bde 100644 --- a/sentry_sdk/metrics.py +++ b/sentry_sdk/metrics.py @@ -5,6 +5,7 @@ import sys import threading import time +import warnings import zlib from abc import ABC, abstractmethod from contextlib import contextmanager @@ -27,7 +28,8 @@ TRANSACTION_SOURCE_COMPONENT, TRANSACTION_SOURCE_TASK, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -53,6 +55,14 @@ from sentry_sdk._types import MetricValue +warnings.warn( + "The sentry_sdk.metrics module is deprecated and will be removed in the next major release. " + "Sentry will reject all metrics sent after October 7, 2024. " + "Learn more: https://sentry.zendesk.com/hc/en-us/articles/26369339769883-Upcoming-API-Changes-to-Metrics", + DeprecationWarning, + stacklevel=2, +) + _in_metrics = ContextVar("in_metrics", default=False) _set = set # set is shadowed below @@ -816,7 +826,7 @@ def __enter__(self): # type: (...) -> _Timing self.entered = TIMING_FUNCTIONS[self.unit]() self._validate_invocation("context-manager") - self._span = sentry_sdk.start_span(op="metric.timing", description=self.key) + self._span = sentry_sdk.start_span(op="metric.timing", name=self.key) if self.tags: for key, value in self.tags.items(): if isinstance(value, (tuple, list)): diff --git a/sentry_sdk/monitor.py b/sentry_sdk/monitor.py index f94e0d4e0d..68d9017bf9 100644 --- a/sentry_sdk/monitor.py +++ b/sentry_sdk/monitor.py @@ -4,7 +4,8 @@ import sentry_sdk from sentry_sdk.utils import logger -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional diff --git a/sentry_sdk/profiler/continuous_profiler.py b/sentry_sdk/profiler/continuous_profiler.py index 63a9201b6f..5d64896b93 100644 --- a/sentry_sdk/profiler/continuous_profiler.py +++ b/sentry_sdk/profiler/continuous_profiler.py @@ -9,7 +9,6 @@ from sentry_sdk.consts import VERSION from sentry_sdk.envelope import Envelope from sentry_sdk._lru_cache import LRUCache -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.profiler.utils import ( DEFAULT_SAMPLING_FREQUENCY, extract_stack, @@ -22,6 +21,7 @@ set_in_app_in_frames, ) +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -164,7 +164,7 @@ def get_profiler_id(): return _scheduler.profiler_id -class ContinuousScheduler(object): +class ContinuousScheduler: mode = "unknown" # type: ContinuousProfilerMode def __init__(self, frequency, options, sdk_info, capture_func): @@ -410,7 +410,7 @@ def teardown(self): PROFILE_BUFFER_SECONDS = 10 -class ProfileBuffer(object): +class ProfileBuffer: def __init__(self, options, sdk_info, buffer_size, capture_func): # type: (Dict[str, Any], SDKInfo, int, Callable[[Envelope], None]) -> None self.options = options @@ -458,7 +458,7 @@ def flush(self): self.capture_func(envelope) -class ProfileChunk(object): +class ProfileChunk: def __init__(self): # type: () -> None self.chunk_id = uuid.uuid4().hex diff --git a/sentry_sdk/profiler/transaction_profiler.py b/sentry_sdk/profiler/transaction_profiler.py index 6ed983fb59..f579c441fa 100644 --- a/sentry_sdk/profiler/transaction_profiler.py +++ b/sentry_sdk/profiler/transaction_profiler.py @@ -39,7 +39,6 @@ import sentry_sdk from sentry_sdk._lru_cache import LRUCache -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.profiler.utils import ( DEFAULT_SAMPLING_FREQUENCY, extract_stack, @@ -54,6 +53,8 @@ set_in_app_in_frames, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable diff --git a/sentry_sdk/profiler/utils.py b/sentry_sdk/profiler/utils.py index 682274d00d..3554cddb5d 100644 --- a/sentry_sdk/profiler/utils.py +++ b/sentry_sdk/profiler/utils.py @@ -2,9 +2,10 @@ from collections import deque from sentry_sdk._compat import PY311 -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import filename_for_module +from typing import TYPE_CHECKING + if TYPE_CHECKING: from sentry_sdk._lru_cache import LRUCache from types import FrameType @@ -88,7 +89,7 @@ def get_frame_name(frame): and co_varnames[0] == "self" and "self" in frame.f_locals ): - for cls in frame.f_locals["self"].__class__.__mro__: + for cls in type(frame.f_locals["self"]).__mro__: if name in cls.__dict__: return "{}.{}".format(cls.__name__, name) except (AttributeError, ValueError): diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 4e07e818c9..bb45143c48 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1,5 +1,6 @@ import os import sys +import warnings from copy import copy from collections import deque from contextlib import contextmanager @@ -10,6 +11,7 @@ from sentry_sdk.attachments import Attachment from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, FALSE_VALUES, INSTRUMENTER +from sentry_sdk.flag_utils import FlagBuffer, DEFAULT_FLAG_CAPACITY from sentry_sdk.profiler.continuous_profiler import try_autostart_continuous_profiler from sentry_sdk.profiler.transaction_profiler import Profile from sentry_sdk.session import Session @@ -26,16 +28,19 @@ Span, Transaction, ) -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import ( capture_internal_exception, capture_internal_exceptions, ContextVar, + datetime_from_isoformat, + disable_capture_event, event_from_exception, exc_info_from_error, logger, ) +from typing import TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Mapping, MutableMapping @@ -152,7 +157,7 @@ def wrapper(self, *args, **kwargs): return wrapper # type: ignore -class Scope(object): +class Scope: """The scope holds extra information that should be sent with all events that belong to it. """ @@ -188,6 +193,7 @@ class Scope(object): "client", "_type", "_last_event_id", + "_flags", ) def __init__(self, ty=None, client=None): @@ -219,6 +225,7 @@ def __copy__(self): rv = object.__new__(self.__class__) # type: Scope rv._type = self._type + rv.client = self.client rv._level = self._level rv._name = self._name rv._fingerprint = self._fingerprint @@ -245,6 +252,8 @@ def __copy__(self): rv._last_event_id = self._last_event_id + rv._flags = copy(self._flags) + return rv @classmethod @@ -681,6 +690,7 @@ def clear(self): # self._last_event_id is only applicable to isolation scopes self._last_event_id = None # type: Optional[str] + self._flags = None # type: Optional[FlagBuffer] @_attr_setter def level(self, value): @@ -964,7 +974,7 @@ def start_transaction( transaction=None, instrumenter=INSTRUMENTER.SENTRY, custom_sampling_context=None, - **kwargs + **kwargs, ): # type: (Optional[Transaction], str, Optional[SamplingContext], Unpack[TransactionKwargs]) -> Union[Transaction, NoOpSpan] """ @@ -1064,6 +1074,13 @@ def start_span(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): be removed in the next major version. Going forward, it should only be used by the SDK itself. """ + if kwargs.get("description") is not None: + warnings.warn( + "The `description` parameter is deprecated. Please use `name` instead.", + DeprecationWarning, + stacklevel=2, + ) + with new_scope(): kwargs.setdefault("scope", self) @@ -1130,6 +1147,9 @@ def capture_event(self, event, hint=None, scope=None, **scope_kwargs): :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). """ + if disable_capture_event.get(False): + return None + scope = self._merge_scopes(scope, scope_kwargs) event_id = self.get_client().capture_event(event=event, hint=hint, scope=scope) @@ -1157,6 +1177,9 @@ def capture_message(self, message, level=None, scope=None, **scope_kwargs): :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). """ + if disable_capture_event.get(False): + return None + if level is None: level = "info" @@ -1182,6 +1205,9 @@ def capture_exception(self, error=None, scope=None, **scope_kwargs): :returns: An `event_id` if the SDK decided to send the event (see :py:meth:`sentry_sdk.client._Client.capture_event`). """ + if disable_capture_event.get(False): + return None + if error is not None: exc_info = exc_info_from_error(error) else: @@ -1296,7 +1322,17 @@ def _apply_breadcrumbs_to_event(self, event, hint, options): event.setdefault("breadcrumbs", {}).setdefault("values", []).extend( self._breadcrumbs ) - event["breadcrumbs"]["values"].sort(key=lambda crumb: crumb["timestamp"]) + + # Attempt to sort timestamps + try: + for crumb in event["breadcrumbs"]["values"]: + if isinstance(crumb["timestamp"], str): + crumb["timestamp"] = datetime_from_isoformat(crumb["timestamp"]) + + event["breadcrumbs"]["values"].sort(key=lambda crumb: crumb["timestamp"]) + except Exception as err: + logger.debug("Error when sorting breadcrumbs", exc_info=err) + pass def _apply_user_to_event(self, event, hint, options): # type: (Event, Hint, Optional[Dict[str, Any]]) -> None @@ -1516,6 +1552,17 @@ def __repr__(self): self._type, ) + @property + def flags(self): + # type: () -> FlagBuffer + if self._flags is None: + max_flags = ( + self.get_client().options["_experiments"].get("max_flags") + or DEFAULT_FLAG_CAPACITY + ) + self._flags = FlagBuffer(capacity=max_flags) + return self._flags + @contextmanager def new_scope(): diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py index f1f320786c..f4755ea93b 100644 --- a/sentry_sdk/scrubber.py +++ b/sentry_sdk/scrubber.py @@ -3,7 +3,8 @@ AnnotatedValue, iter_event_frames, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from sentry_sdk._types import Event @@ -24,21 +25,17 @@ "privatekey", "private_key", "token", - "ip_address", "session", # django "csrftoken", "sessionid", # wsgi - "remote_addr", "x_csrftoken", "x_forwarded_for", "set_cookie", "cookie", "authorization", "x_api_key", - "x_forwarded_for", - "x_real_ip", # other common names used in the wild "aiohttp_session", # aiohttp "connect.sid", # Express @@ -54,11 +51,35 @@ "XSRF-TOKEN", # Angular, Laravel ] +DEFAULT_PII_DENYLIST = [ + "x_forwarded_for", + "x_real_ip", + "ip_address", + "remote_addr", +] + + +class EventScrubber: + def __init__( + self, denylist=None, recursive=False, send_default_pii=False, pii_denylist=None + ): + # type: (Optional[List[str]], bool, bool, Optional[List[str]]) -> None + """ + A scrubber that goes through the event payload and removes sensitive data configured through denylists. + + :param denylist: A security denylist that is always scrubbed, defaults to DEFAULT_DENYLIST. + :param recursive: Whether to scrub the event payload recursively, default False. + :param send_default_pii: Whether pii is sending is on, pii fields are not scrubbed. + :param pii_denylist: The denylist to use for scrubbing when pii is not sent, defaults to DEFAULT_PII_DENYLIST. + """ + self.denylist = DEFAULT_DENYLIST.copy() if denylist is None else denylist + + if not send_default_pii: + pii_denylist = ( + DEFAULT_PII_DENYLIST.copy() if pii_denylist is None else pii_denylist + ) + self.denylist += pii_denylist -class EventScrubber(object): - def __init__(self, denylist=None, recursive=False): - # type: (Optional[List[str]], bool) -> None - self.denylist = DEFAULT_DENYLIST if denylist is None else denylist self.denylist = [x.lower() for x in self.denylist] self.recursive = recursive diff --git a/sentry_sdk/serializer.py b/sentry_sdk/serializer.py index ff243eeadc..bc8e38c631 100644 --- a/sentry_sdk/serializer.py +++ b/sentry_sdk/serializer.py @@ -11,7 +11,8 @@ safe_repr, strip_string, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from types import TracebackType @@ -25,7 +26,7 @@ from typing import Type from typing import Union - from sentry_sdk._types import NotImplementedType, Event + from sentry_sdk._types import NotImplementedType Span = Dict[str, Any] @@ -95,7 +96,26 @@ def __exit__( def serialize(event, **kwargs): - # type: (Event, **Any) -> Event + # type: (Dict[str, Any], **Any) -> Dict[str, Any] + """ + A very smart serializer that takes a dict and emits a json-friendly dict. + Currently used for serializing the final Event and also prematurely while fetching the stack + local variables for each frame in a stacktrace. + + It works internally with 'databags' which are arbitrary data structures like Mapping, Sequence and Set. + The algorithm itself is a recursive graph walk down the data structures it encounters. + + It has the following responsibilities: + * Trimming databags and keeping them within MAX_DATABAG_BREADTH and MAX_DATABAG_DEPTH. + * Calling safe_repr() on objects appropriately to keep them informative and readable in the final payload. + * Annotating the payload with the _meta field whenever trimming happens. + + :param max_request_body_size: If set to "always", will never trim request bodies. + :param max_value_length: The max length to strip strings to, defaults to sentry_sdk.consts.DEFAULT_MAX_VALUE_LENGTH + :param is_vars: If we're serializing vars early, we want to repr() things that are JSON-serializable to make their type more apparent. For example, it's useful to see the difference between a unicode-string and a bytestring when viewing a stacktrace. + :param custom_repr: A custom repr function that runs before safe_repr on the object to be serialized. If it returns None or throws internally, we will fallback to safe_repr. + + """ memo = Memo() path = [] # type: List[Segment] meta_stack = [] # type: List[Dict[str, Any]] @@ -104,6 +124,18 @@ def serialize(event, **kwargs): kwargs.pop("max_request_body_size", None) == "always" ) # type: bool max_value_length = kwargs.pop("max_value_length", None) # type: Optional[int] + is_vars = kwargs.pop("is_vars", False) + custom_repr = kwargs.pop("custom_repr", None) # type: Callable[..., Optional[str]] + + def _safe_repr_wrapper(value): + # type: (Any) -> str + try: + repr_value = None + if custom_repr is not None: + repr_value = custom_repr(value) + return repr_value or safe_repr(value) + except Exception: + return safe_repr(value) def _annotate(**meta): # type: (**Any) -> None @@ -118,56 +150,17 @@ def _annotate(**meta): meta_stack[-1].setdefault("", {}).update(meta) - def _should_repr_strings(): - # type: () -> Optional[bool] - """ - By default non-serializable objects are going through - safe_repr(). For certain places in the event (local vars) we - want to repr() even things that are JSON-serializable to - make their type more apparent. For example, it's useful to - see the difference between a unicode-string and a bytestring - when viewing a stacktrace. - - For container-types we still don't do anything different. - Generally we just try to make the Sentry UI present exactly - what a pretty-printed repr would look like. - - :returns: `True` if we are somewhere in frame variables, and `False` if - we are in a position where we will never encounter frame variables - when recursing (for example, we're in `event.extra`). `None` if we - are not (yet) in frame variables, but might encounter them when - recursing (e.g. we're in `event.exception`) - """ - try: - p0 = path[0] - if p0 == "stacktrace" and path[1] == "frames" and path[3] == "vars": - return True - - if ( - p0 in ("threads", "exception") - and path[1] == "values" - and path[3] == "stacktrace" - and path[4] == "frames" - and path[6] == "vars" - ): - return True - except IndexError: - return None - - return False - def _is_databag(): # type: () -> Optional[bool] """ A databag is any value that we need to trim. + True for stuff like vars, request bodies, breadcrumbs and extra. - :returns: Works like `_should_repr_strings()`. `True` for "yes", - `False` for :"no", `None` for "maybe soon". + :returns: `True` for "yes", `False` for :"no", `None` for "maybe soon". """ try: - rv = _should_repr_strings() - if rv in (True, None): - return rv + if is_vars: + return True is_request_body = _is_request_body() if is_request_body in (True, None): @@ -253,7 +246,7 @@ def _serialize_node_impl( if isinstance(obj, AnnotatedValue): should_repr_strings = False if should_repr_strings is None: - should_repr_strings = _should_repr_strings() + should_repr_strings = is_vars if is_databag is None: is_databag = _is_databag() @@ -277,7 +270,7 @@ def _serialize_node_impl( _annotate(rem=[["!limit", "x"]]) if is_databag: return _flatten_annotated( - strip_string(safe_repr(obj), max_length=max_value_length) + strip_string(_safe_repr_wrapper(obj), max_length=max_value_length) ) return None @@ -294,7 +287,7 @@ def _serialize_node_impl( if should_repr_strings or ( isinstance(obj, float) and (math.isinf(obj) or math.isnan(obj)) ): - return safe_repr(obj) + return _safe_repr_wrapper(obj) else: return obj @@ -305,7 +298,7 @@ def _serialize_node_impl( return ( str(format_timestamp(obj)) if not should_repr_strings - else safe_repr(obj) + else _safe_repr_wrapper(obj) ) elif isinstance(obj, Mapping): @@ -365,13 +358,13 @@ def _serialize_node_impl( return rv_list if should_repr_strings: - obj = safe_repr(obj) + obj = _safe_repr_wrapper(obj) else: if isinstance(obj, bytes) or isinstance(obj, bytearray): obj = obj.decode("utf-8", "replace") if not isinstance(obj, str): - obj = safe_repr(obj) + obj = _safe_repr_wrapper(obj) is_span_description = ( len(path) == 3 and path[0] == "spans" and path[-1] == "description" @@ -387,7 +380,7 @@ def _serialize_node_impl( disable_capture_event.set(True) try: serialized_event = _serialize_node(event, **kwargs) - if meta_stack and isinstance(serialized_event, dict): + if not is_vars and meta_stack and isinstance(serialized_event, dict): serialized_event["_meta"] = meta_stack[0] return serialized_event diff --git a/sentry_sdk/session.py b/sentry_sdk/session.py index 5c11456430..c1d422c115 100644 --- a/sentry_sdk/session.py +++ b/sentry_sdk/session.py @@ -1,9 +1,10 @@ import uuid from datetime import datetime, timezone -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import format_timestamp +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Optional from typing import Union diff --git a/sentry_sdk/sessions.py b/sentry_sdk/sessions.py index b14bc43187..eaeb915e7b 100644 --- a/sentry_sdk/sessions.py +++ b/sentry_sdk/sessions.py @@ -1,14 +1,16 @@ import os import time +import warnings from threading import Thread, Lock from contextlib import contextmanager import sentry_sdk from sentry_sdk.envelope import Envelope from sentry_sdk.session import Session -from sentry_sdk._types import TYPE_CHECKING from sentry_sdk.utils import format_timestamp +from typing import TYPE_CHECKING + if TYPE_CHECKING: from typing import Any from typing import Callable @@ -21,8 +23,15 @@ def is_auto_session_tracking_enabled(hub=None): # type: (Optional[sentry_sdk.Hub]) -> Union[Any, bool, None] - """Utility function to find out if session tracking is enabled.""" - # TODO: add deprecation warning + """DEPRECATED: Utility function to find out if session tracking is enabled.""" + + # Internal callers should use private _is_auto_session_tracking_enabled, instead. + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "There is no public API replacement.", + DeprecationWarning, + stacklevel=2, + ) if hub is None: hub = sentry_sdk.Hub.current @@ -39,12 +48,21 @@ def is_auto_session_tracking_enabled(hub=None): @contextmanager def auto_session_tracking(hub=None, session_mode="application"): # type: (Optional[sentry_sdk.Hub], str) -> Generator[None, None, None] - """Starts and stops a session automatically around a block.""" - # TODO: add deprecation warning + """DEPRECATED: Use track_session instead + Starts and stops a session automatically around a block. + """ + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "Use track_session instead.", + DeprecationWarning, + stacklevel=2, + ) if hub is None: hub = sentry_sdk.Hub.current - should_track = is_auto_session_tracking_enabled(hub) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + should_track = is_auto_session_tracking_enabled(hub) if should_track: hub.start_session(session_mode=session_mode) try: @@ -57,12 +75,26 @@ def auto_session_tracking(hub=None, session_mode="application"): def is_auto_session_tracking_enabled_scope(scope): # type: (sentry_sdk.Scope) -> bool """ - Utility function to find out if session tracking is enabled. + DEPRECATED: Utility function to find out if session tracking is enabled. + """ - TODO: This uses the new scopes. When the Hub is removed, the function - is_auto_session_tracking_enabled should be removed and this function - should be renamed to is_auto_session_tracking_enabled. + warnings.warn( + "This function is deprecated and will be removed in the next major release. " + "There is no public API replacement.", + DeprecationWarning, + stacklevel=2, + ) + + # Internal callers should use private _is_auto_session_tracking_enabled, instead. + return _is_auto_session_tracking_enabled(scope) + + +def _is_auto_session_tracking_enabled(scope): + # type: (sentry_sdk.Scope) -> bool + """ + Utility function to find out if session tracking is enabled. """ + should_track = scope._force_auto_session_tracking if should_track is None: client_options = sentry_sdk.get_client().options @@ -74,14 +106,29 @@ def is_auto_session_tracking_enabled_scope(scope): @contextmanager def auto_session_tracking_scope(scope, session_mode="application"): # type: (sentry_sdk.Scope, str) -> Generator[None, None, None] - """ + """DEPRECATED: This function is a deprecated alias for track_session. Starts and stops a session automatically around a block. + """ + + warnings.warn( + "This function is a deprecated alias for track_session and will be removed in the next major release.", + DeprecationWarning, + stacklevel=2, + ) + + with track_session(scope, session_mode=session_mode): + yield - TODO: This uses the new scopes. When the Hub is removed, the function - auto_session_tracking should be removed and this function - should be renamed to auto_session_tracking. + +@contextmanager +def track_session(scope, session_mode="application"): + # type: (sentry_sdk.Scope, str) -> Generator[None, None, None] """ - should_track = is_auto_session_tracking_enabled_scope(scope) + Start a new session in the provided scope, assuming session tracking is enabled. + This is a no-op context manager if session tracking is not enabled. + """ + + should_track = _is_auto_session_tracking_enabled(scope) if should_track: scope.start_session(session_mode=session_mode) try: diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 76d0d61468..a783b155a1 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -1,17 +1,38 @@ import io +import logging +import os +import urllib.parse +import urllib.request +import urllib.error import urllib3 +import sys -from sentry_sdk._types import TYPE_CHECKING +from itertools import chain, product + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any + from typing import Callable from typing import Dict from typing import Optional + from typing import Self -from sentry_sdk.utils import logger +from sentry_sdk.utils import ( + logger as sentry_logger, + env_to_bool, + capture_internal_exceptions, +) from sentry_sdk.envelope import Envelope +logger = logging.getLogger("spotlight") + + +DEFAULT_SPOTLIGHT_URL = "http://localhost:8969/stream" +DJANGO_SPOTLIGHT_MIDDLEWARE_PATH = "sentry_sdk.spotlight.SpotlightMiddleware" + + class SpotlightClient: def __init__(self, url): # type: (str) -> None @@ -21,11 +42,6 @@ def __init__(self, url): def capture_envelope(self, envelope): # type: (Envelope) -> None - if self.tries > 3: - logger.warning( - "Too many errors sending to Spotlight, stop sending events there." - ) - return body = io.BytesIO() envelope.serialize_into(body) try: @@ -39,20 +55,178 @@ def capture_envelope(self, envelope): ) req.close() except Exception as e: - self.tries += 1 - logger.warning(str(e)) + # TODO: Implement buffering and retrying with exponential backoff + sentry_logger.warning(str(e)) + + +try: + from django.utils.deprecation import MiddlewareMixin + from django.http import HttpResponseServerError, HttpResponse, HttpRequest + from django.conf import settings + + SPOTLIGHT_JS_ENTRY_PATH = "/assets/main.js" + SPOTLIGHT_JS_SNIPPET_PATTERN = ( + "\n" + '\n' + ) + SPOTLIGHT_ERROR_PAGE_SNIPPET = ( + '\n' + '\n' + ) + CHARSET_PREFIX = "charset=" + BODY_TAG_NAME = "body" + BODY_CLOSE_TAG_POSSIBILITIES = tuple( + "".format("".join(chars)) + for chars in product(*zip(BODY_TAG_NAME.upper(), BODY_TAG_NAME.lower())) + ) + + class SpotlightMiddleware(MiddlewareMixin): # type: ignore[misc] + _spotlight_script = None # type: Optional[str] + + def __init__(self, get_response): + # type: (Self, Callable[..., HttpResponse]) -> None + super().__init__(get_response) + + import sentry_sdk.api + + self.sentry_sdk = sentry_sdk.api + + spotlight_client = self.sentry_sdk.get_client().spotlight + if spotlight_client is None: + sentry_logger.warning( + "Cannot find Spotlight client from SpotlightMiddleware, disabling the middleware." + ) + return None + # Spotlight URL has a trailing `/stream` part at the end so split it off + self._spotlight_url = urllib.parse.urljoin(spotlight_client.url, "../") + + @property + def spotlight_script(self): + # type: (Self) -> Optional[str] + if self._spotlight_script is None: + try: + spotlight_js_url = urllib.parse.urljoin( + self._spotlight_url, SPOTLIGHT_JS_ENTRY_PATH + ) + req = urllib.request.Request( + spotlight_js_url, + method="HEAD", + ) + urllib.request.urlopen(req) + self._spotlight_script = SPOTLIGHT_JS_SNIPPET_PATTERN.format( + spotlight_url=self._spotlight_url, + spotlight_js_url=spotlight_js_url, + ) + except urllib.error.URLError as err: + sentry_logger.debug( + "Cannot get Spotlight JS to inject at %s. SpotlightMiddleware will not be very useful.", + spotlight_js_url, + exc_info=err, + ) + + return self._spotlight_script + + def process_response(self, _request, response): + # type: (Self, HttpRequest, HttpResponse) -> Optional[HttpResponse] + content_type_header = tuple( + p.strip() + for p in response.headers.get("Content-Type", "").lower().split(";") + ) + content_type = content_type_header[0] + if len(content_type_header) > 1 and content_type_header[1].startswith( + CHARSET_PREFIX + ): + encoding = content_type_header[1][len(CHARSET_PREFIX) :] + else: + encoding = "utf-8" + + if ( + self.spotlight_script is not None + and not response.streaming + and content_type == "text/html" + ): + content_length = len(response.content) + injection = self.spotlight_script.encode(encoding) + injection_site = next( + ( + idx + for idx in ( + response.content.rfind(body_variant.encode(encoding)) + for body_variant in BODY_CLOSE_TAG_POSSIBILITIES + ) + if idx > -1 + ), + content_length, + ) + + # This approach works even when we don't have a `` tag + response.content = ( + response.content[:injection_site] + + injection + + response.content[injection_site:] + ) + + if response.has_header("Content-Length"): + response.headers["Content-Length"] = content_length + len(injection) + + return response + + def process_exception(self, _request, exception): + # type: (Self, HttpRequest, Exception) -> Optional[HttpResponseServerError] + if not settings.DEBUG: + return None + + try: + spotlight = ( + urllib.request.urlopen(self._spotlight_url).read().decode("utf-8") + ) + except urllib.error.URLError: + return None + else: + event_id = self.sentry_sdk.capture_exception(exception) + return HttpResponseServerError( + spotlight.replace( + "", + SPOTLIGHT_ERROR_PAGE_SNIPPET.format( + spotlight_url=self._spotlight_url, event_id=event_id + ), + ) + ) + +except ImportError: + settings = None def setup_spotlight(options): # type: (Dict[str, Any]) -> Optional[SpotlightClient] + _handler = logging.StreamHandler(sys.stderr) + _handler.setFormatter(logging.Formatter(" [spotlight] %(levelname)s: %(message)s")) + logger.addHandler(_handler) + logger.setLevel(logging.INFO) url = options.get("spotlight") - if isinstance(url, str): - pass - elif url is True: - url = "http://localhost:8969/stream" - else: + if url is True: + url = DEFAULT_SPOTLIGHT_URL + + if not isinstance(url, str): return None - return SpotlightClient(url) + with capture_internal_exceptions(): + if ( + settings is not None + and settings.DEBUG + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1")) + and env_to_bool(os.environ.get("SENTRY_SPOTLIGHT_MIDDLEWARE", "1")) + ): + middleware = settings.MIDDLEWARE + if DJANGO_SPOTLIGHT_MIDDLEWARE_PATH not in middleware: + settings.MIDDLEWARE = type(middleware)( + chain(middleware, (DJANGO_SPOTLIGHT_MIDDLEWARE_PATH,)) + ) + logger.info("Enabled Spotlight integration for Django") + + client = SpotlightClient(url) + logger.info("Enabled Spotlight using sidecar at %s", url) + + return client diff --git a/sentry_sdk/tracing.py b/sentry_sdk/tracing.py index b451fcfe0b..3868b2e6c8 100644 --- a/sentry_sdk/tracing.py +++ b/sentry_sdk/tracing.py @@ -12,7 +12,8 @@ logger, nanosecond_time, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable, Mapping, MutableMapping @@ -69,7 +70,7 @@ class SpanKwargs(TypedDict, total=False): """ description: str - """A description of what operation is being performed within the span.""" + """A description of what operation is being performed within the span. This argument is DEPRECATED. Please use the `name` parameter, instead.""" hub: Optional["sentry_sdk.Hub"] """The hub to use for this span. This argument is DEPRECATED. Please use the `scope` parameter, instead.""" @@ -96,10 +97,10 @@ class SpanKwargs(TypedDict, total=False): Default "manual". """ - class TransactionKwargs(SpanKwargs, total=False): name: str - """Identifier of the transaction. Will show up in the Sentry UI.""" + """A string describing what operation is being performed within the span/transaction.""" + class TransactionKwargs(SpanKwargs, total=False): source: str """ A string describing the source of the transaction name. This will be used to determine the transaction's type. @@ -226,6 +227,10 @@ class Span: :param op: The span's operation. A list of recommended values is available here: https://develop.sentry.dev/sdk/performance/span-operations/ :param description: A description of what operation is being performed within the span. + + .. deprecated:: 2.15.0 + Please use the `name` parameter, instead. + :param name: A string describing what operation is being performed within the span. :param hub: The hub to use for this span. .. deprecated:: 2.0.0 @@ -260,6 +265,7 @@ class Span: "_local_aggregator", "scope", "origin", + "name", ) def __init__( @@ -277,6 +283,7 @@ def __init__( start_timestamp=None, # type: Optional[Union[datetime, float]] scope=None, # type: Optional[sentry_sdk.Scope] origin="manual", # type: str + name=None, # type: Optional[str] ): # type: (...) -> None self.trace_id = trace_id or uuid.uuid4().hex @@ -285,7 +292,7 @@ def __init__( self.same_process_as_parent = same_process_as_parent self.sampled = sampled self.op = op - self.description = description + self.description = name or description self.status = status self.hub = hub # backwards compatibility self.scope = scope @@ -322,8 +329,7 @@ def __init__( self._span_recorder = None # type: Optional[_SpanRecorder] self._local_aggregator = None # type: Optional[LocalAggregator] - thread_id, thread_name = get_current_thread_meta() - self.set_thread(thread_id, thread_name) + self.update_active_thread() self.set_profiler_id(get_profiler_id()) # TODO this should really live on the Transaction class rather than the Span @@ -399,6 +405,13 @@ def start_child(self, instrumenter=INSTRUMENTER.SENTRY, **kwargs): be removed in the next major version. Going forward, it should only be used by the SDK itself. """ + if kwargs.get("description") is not None: + warnings.warn( + "The `description` parameter is deprecated. Please use `name` instead.", + DeprecationWarning, + stacklevel=2, + ) + configuration_instrumenter = sentry_sdk.get_client().options["instrumenter"] if instrumenter != configuration_instrumenter: @@ -718,6 +731,11 @@ def get_profile_context(self): "profiler_id": profiler_id, } + def update_active_thread(self): + # type: () -> None + thread_id, thread_name = get_current_thread_meta() + self.set_thread(thread_id, thread_name) + class Transaction(Span): """The Transaction is the root element that holds all the spans @@ -749,7 +767,7 @@ class Transaction(Span): "_baggage", ) - def __init__( + def __init__( # type: ignore[misc] self, name="", # type: str parent_sampled=None, # type: Optional[bool] @@ -1297,4 +1315,8 @@ async def my_async_function(): has_tracing_enabled, maybe_create_breadcrumbs_from_span, ) -from sentry_sdk.metrics import LocalAggregator + +with warnings.catch_warnings(): + # The code in this file which uses `LocalAggregator` is only called from the deprecated `metrics` module. + warnings.simplefilter("ignore", DeprecationWarning) + from sentry_sdk.metrics import LocalAggregator diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 0dabfbc486..0459563776 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -21,9 +21,11 @@ to_string, is_sentry_url, _is_external_source, + _is_in_project_root, _module_in_list, ) -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any @@ -144,7 +146,7 @@ def record_sql_queries( with sentry_sdk.start_span( op=OP.DB, - description=query, + name=query, origin=span_origin, ) as span: for k, v in data.items(): @@ -170,6 +172,34 @@ def maybe_create_breadcrumbs_from_span(scope, span): ) +def _get_frame_module_abs_path(frame): + # type: (FrameType) -> Optional[str] + try: + return frame.f_code.co_filename + except Exception: + return None + + +def _should_be_included( + is_sentry_sdk_frame, # type: bool + namespace, # type: Optional[str] + in_app_include, # type: Optional[list[str]] + in_app_exclude, # type: Optional[list[str]] + abs_path, # type: Optional[str] + project_root, # type: Optional[str] +): + # type: (...) -> bool + # in_app_include takes precedence over in_app_exclude + should_be_included = _module_in_list(namespace, in_app_include) + should_be_excluded = _is_external_source(abs_path) or _module_in_list( + namespace, in_app_exclude + ) + return not is_sentry_sdk_frame and ( + should_be_included + or (_is_in_project_root(abs_path, project_root) and not should_be_excluded) + ) + + def add_query_source(span): # type: (sentry_sdk.tracing.Span) -> None """ @@ -200,10 +230,7 @@ def add_query_source(span): # Find the correct frame frame = sys._getframe() # type: Union[FrameType, None] while frame is not None: - try: - abs_path = frame.f_code.co_filename - except Exception: - abs_path = "" + abs_path = _get_frame_module_abs_path(frame) try: namespace = frame.f_globals.get("__name__") # type: Optional[str] @@ -214,20 +241,15 @@ def add_query_source(span): "sentry_sdk." ) - should_be_included = not _is_external_source(abs_path) - if namespace is not None: - if in_app_exclude and _module_in_list(namespace, in_app_exclude): - should_be_included = False - if in_app_include and _module_in_list(namespace, in_app_include): - # in_app_include takes precedence over in_app_exclude, so doing it - # at the end - should_be_included = True - - if ( - abs_path.startswith(project_root) - and should_be_included - and not is_sentry_sdk_frame - ): + should_be_included = _should_be_included( + is_sentry_sdk_frame=is_sentry_sdk_frame, + namespace=namespace, + in_app_include=in_app_include, + in_app_exclude=in_app_exclude, + abs_path=abs_path, + project_root=project_root, + ) + if should_be_included: break frame = frame.f_back @@ -250,10 +272,7 @@ def add_query_source(span): if namespace is not None: span.set_data(SPANDATA.CODE_NAMESPACE, namespace) - try: - filepath = frame.f_code.co_filename - except Exception: - filepath = None + filepath = _get_frame_module_abs_path(frame) if filepath is not None: if namespace is not None: in_app_path = filename_for_module(namespace, filepath) @@ -513,7 +532,7 @@ def from_options(cls, scope): sentry_items["public_key"] = Dsn(options["dsn"]).public_key if options.get("traces_sample_rate"): - sentry_items["sample_rate"] = options["traces_sample_rate"] + sentry_items["sample_rate"] = str(options["traces_sample_rate"]) return Baggage(sentry_items, third_party_items, mutable) @@ -590,6 +609,21 @@ def serialize(self, include_third_party=False): return ",".join(items) + @staticmethod + def strip_sentry_baggage(header): + # type: (str) -> str + """Remove Sentry baggage from the given header. + + Given a Baggage header, return a new Baggage header with all Sentry baggage items removed. + """ + return ",".join( + ( + item + for item in header.split(",") + if not Baggage.SENTRY_PREFIX_REGEX.match(item.strip()) + ) + ) + def should_propagate_trace(client, url): # type: (sentry_sdk.client.BaseClient, str) -> bool @@ -646,7 +680,7 @@ async def func_with_tracing(*args, **kwargs): with span.start_child( op=OP.FUNCTION, - description=qualname_from_function(func), + name=qualname_from_function(func), ): return await func(*args, **kwargs) @@ -674,7 +708,7 @@ def func_with_tracing(*args, **kwargs): with span.start_child( op=OP.FUNCTION, - description=qualname_from_function(func), + name=qualname_from_function(func), ): return func(*args, **kwargs) diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index e5c39c48e4..8798115898 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -3,12 +3,18 @@ import os import gzip import socket +import ssl import time import warnings from datetime import datetime, timedelta, timezone from collections import defaultdict from urllib.request import getproxies +try: + import brotli # type: ignore +except ImportError: + brotli = None + import urllib3 import certifi @@ -17,19 +23,22 @@ from sentry_sdk.utils import Dsn, logger, capture_internal_exceptions from sentry_sdk.worker import BackgroundWorker from sentry_sdk.envelope import Envelope, Item, PayloadRef -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Callable from typing import Dict + from typing import DefaultDict from typing import Iterable from typing import List + from typing import Mapping from typing import Optional + from typing import Self from typing import Tuple from typing import Type from typing import Union - from typing import DefaultDict from urllib3.poolmanager import PoolManager from urllib3.poolmanager import ProxyManager @@ -59,20 +68,16 @@ class Transport(ABC): parsed_dsn = None # type: Optional[Dsn] - def __init__( - self, options=None # type: Optional[Dict[str, Any]] - ): - # type: (...) -> None + def __init__(self, options=None): + # type: (Self, Optional[Dict[str, Any]]) -> None self.options = options if options and options["dsn"] is not None and options["dsn"]: self.parsed_dsn = Dsn(options["dsn"]) else: self.parsed_dsn = None - def capture_event( - self, event # type: Event - ): - # type: (...) -> None + def capture_event(self, event): + # type: (Self, Event) -> None """ DEPRECATED: Please use capture_envelope instead. @@ -91,25 +96,23 @@ def capture_event( self.capture_envelope(envelope) @abstractmethod - def capture_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def capture_envelope(self, envelope): + # type: (Self, Envelope) -> None """ Send an envelope to Sentry. Envelopes are a data container format that can hold any type of data submitted to Sentry. We use it to send all event data (including errors, - transactions, crons checkins, etc.) to Sentry. + transactions, crons check-ins, etc.) to Sentry. """ pass def flush( self, - timeout, # type: float - callback=None, # type: Optional[Any] + timeout, + callback=None, ): - # type: (...) -> None + # type: (Self, float, Optional[Any]) -> None """ Wait `timeout` seconds for the current events to be sent out. @@ -119,7 +122,7 @@ def flush( return None def kill(self): - # type: () -> None + # type: (Self) -> None """ Forcefully kills the transport. @@ -154,11 +157,11 @@ def record_lost_event( return None def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return True def __del__(self): - # type: () -> None + # type: (Self) -> None try: self.kill() except Exception: @@ -166,16 +169,16 @@ def __del__(self): def _parse_rate_limits(header, now=None): - # type: (Any, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] + # type: (str, Optional[datetime]) -> Iterable[Tuple[Optional[EventDataCategory], datetime]] if now is None: now = datetime.now(timezone.utc) for limit in header.split(","): try: parameters = limit.strip().split(":") - retry_after, categories = parameters[:2] + retry_after_val, categories = parameters[:2] - retry_after = now + timedelta(seconds=int(retry_after)) + retry_after = now + timedelta(seconds=int(retry_after_val)) for category in categories and categories.split(";") or (None,): if category == "metric_bucket": try: @@ -184,21 +187,19 @@ def _parse_rate_limits(header, now=None): namespaces = [] if not namespaces or "custom" in namespaces: - yield category, retry_after + yield category, retry_after # type: ignore else: - yield category, retry_after + yield category, retry_after # type: ignore except (LookupError, ValueError): continue -class HttpTransport(Transport): - """The default HTTP transport.""" +class BaseHttpTransport(Transport): + """The base HTTP transport.""" - def __init__( - self, options # type: Dict[str, Any] - ): - # type: (...) -> None + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None from sentry_sdk.consts import VERSION Transport.__init__(self, options) @@ -207,33 +208,57 @@ def __init__( self._worker = BackgroundWorker(queue_size=options["transport_queue_size"]) self._auth = self.parsed_dsn.to_auth("sentry.python/%s" % VERSION) self._disabled_until = {} # type: Dict[Optional[EventDataCategory], datetime] + # We only use this Retry() class for the `get_retry_after` method it exposes self._retry = urllib3.util.Retry() self._discarded_events = defaultdict( int ) # type: DefaultDict[Tuple[EventDataCategory, str], int] self._last_client_report_sent = time.time() - compresslevel = options.get("_experiments", {}).get( - "transport_zlib_compression_level" - ) - self._compresslevel = 9 if compresslevel is None else int(compresslevel) - - num_pools = options.get("_experiments", {}).get("transport_num_pools") - self._num_pools = 2 if num_pools is None else int(num_pools) - - self._pool = self._make_pool( - self.parsed_dsn, - http_proxy=options["http_proxy"], - https_proxy=options["https_proxy"], - ca_certs=options["ca_certs"], - cert_file=options["cert_file"], - key_file=options["key_file"], - proxy_headers=options["proxy_headers"], - ) + self._pool = self._make_pool() # Backwards compatibility for deprecated `self.hub_class` attribute self._hub_cls = sentry_sdk.Hub + experiments = options.get("_experiments", {}) + compression_level = experiments.get( + "transport_compression_level", + experiments.get("transport_zlib_compression_level"), + ) + compression_algo = experiments.get( + "transport_compression_algo", + ( + "gzip" + # if only compression level is set, assume gzip for backwards compatibility + # if we don't have brotli available, fallback to gzip + if compression_level is not None or brotli is None + else "br" + ), + ) + + if compression_algo == "br" and brotli is None: + logger.warning( + "You asked for brotli compression without the Brotli module, falling back to gzip -9" + ) + compression_algo = "gzip" + compression_level = None + + if compression_algo not in ("br", "gzip"): + logger.warning( + "Unknown compression algo %s, disabling compression", compression_algo + ) + self._compression_level = 0 + self._compression_algo = None + else: + self._compression_algo = compression_algo + + if compression_level is not None: + self._compression_level = compression_level + elif self._compression_algo == "gzip": + self._compression_level = 9 + elif self._compression_algo == "br": + self._compression_level = 4 + def record_lost_event( self, reason, # type: str @@ -268,12 +293,16 @@ def record_lost_event( self._discarded_events[data_category, reason] += quantity + def _get_header_value(self, response, header): + # type: (Self, Any, str) -> Optional[str] + return response.headers.get(header) + def _update_rate_limits(self, response): - # type: (urllib3.BaseHTTPResponse) -> None + # type: (Self, Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None # new sentries with more rate limit insights. We honor this header # no matter of the status code to update our internal rate limits. - header = response.headers.get("x-sentry-rate-limits") + header = self._get_header_value(response, "x-sentry-rate-limits") if header: logger.warning("Rate-limited via x-sentry-rate-limits") self._disabled_until.update(_parse_rate_limits(header)) @@ -283,18 +312,24 @@ def _update_rate_limits(self, response): # sentries if a proxy in front wants to globally slow things down. elif response.status == 429: logger.warning("Rate-limited via 429") + retry_after_value = self._get_header_value(response, "Retry-After") + retry_after = ( + self._retry.parse_retry_after(retry_after_value) + if retry_after_value is not None + else None + ) or 60 self._disabled_until[None] = datetime.now(timezone.utc) + timedelta( - seconds=self._retry.get_retry_after(response) or 60 + seconds=retry_after ) def _send_request( self, - body, # type: bytes - headers, # type: Dict[str, str] - endpoint_type=EndpointType.ENVELOPE, # type: EndpointType - envelope=None, # type: Optional[Envelope] + body, + headers, + endpoint_type=EndpointType.ENVELOPE, + envelope=None, ): - # type: (...) -> None + # type: (Self, bytes, Dict[str, str], EndpointType, Optional[Envelope]) -> None def record_loss(reason): # type: (str) -> None @@ -311,11 +346,11 @@ def record_loss(reason): } ) try: - response = self._pool.request( + response = self._request( "POST", - str(self._auth.get_api_url(endpoint_type)), - body=body, - headers=headers, + endpoint_type, + body, + headers, ) except Exception: self.on_dropped_event("network") @@ -337,19 +372,19 @@ def record_loss(reason): logger.error( "Unexpected status code: %s (body: %s)", response.status, - response.data, + getattr(response, "data", getattr(response, "content", None)), ) self.on_dropped_event("status_{}".format(response.status)) record_loss("network_error") finally: response.close() - def on_dropped_event(self, reason): - # type: (str) -> None + def on_dropped_event(self, _reason): + # type: (Self, str) -> None return None def _fetch_pending_client_report(self, force=False, interval=60): - # type: (bool, int) -> Optional[Item] + # type: (Self, bool, int) -> Optional[Item] if not self.options["send_client_reports"]: return None @@ -380,7 +415,7 @@ def _fetch_pending_client_report(self, force=False, interval=60): ) def _flush_client_reports(self, force=False): - # type: (bool) -> None + # type: (Self, bool) -> None client_report = self._fetch_pending_client_report(force=force, interval=60) if client_report is not None: self.capture_envelope(Envelope(items=[client_report])) @@ -401,23 +436,21 @@ def _disabled(bucket): return _disabled(category) or _disabled(None) def _is_rate_limited(self): - # type: () -> bool + # type: (Self) -> bool return any( ts > datetime.now(timezone.utc) for ts in self._disabled_until.values() ) def _is_worker_full(self): - # type: () -> bool + # type: (Self) -> bool return self._worker.full() def is_healthy(self): - # type: () -> bool + # type: (Self) -> bool return not (self._is_worker_full() or self._is_rate_limited()) - def _send_envelope( - self, envelope # type: Envelope - ): - # type: (...) -> None + def _send_envelope(self, envelope): + # type: (Self, Envelope) -> None # remove all items from the envelope which are over quota new_items = [] @@ -445,14 +478,7 @@ def _send_envelope( if client_report_item is not None: envelope.items.append(client_report_item) - body = io.BytesIO() - if self._compresslevel == 0: - envelope.serialize_into(body) - else: - with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compresslevel - ) as f: - envelope.serialize_into(f) + content_encoding, body = self._serialize_envelope(envelope) assert self.parsed_dsn is not None logger.debug( @@ -465,8 +491,8 @@ def _send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compresslevel > 0: - headers["Content-Encoding"] = "gzip" + if content_encoding: + headers["Content-Encoding"] = content_encoding self._send_request( body.getvalue(), @@ -476,10 +502,124 @@ def _send_envelope( ) return None - def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): - # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + def _serialize_envelope(self, envelope): + # type: (Self, Envelope) -> tuple[Optional[str], io.BytesIO] + content_encoding = None + body = io.BytesIO() + if self._compression_level == 0 or self._compression_algo is None: + envelope.serialize_into(body) + else: + content_encoding = self._compression_algo + if self._compression_algo == "br" and brotli is not None: + body.write( + brotli.compress( + envelope.serialize(), quality=self._compression_level + ) + ) + else: # assume gzip as we sanitize the algo value in init + with gzip.GzipFile( + fileobj=body, mode="w", compresslevel=self._compression_level + ) as f: + envelope.serialize_into(f) + + return content_encoding, body + + def _get_pool_options(self): + # type: (Self) -> Dict[str, Any] + raise NotImplementedError() + + def _in_no_proxy(self, parsed_dsn): + # type: (Self, Dsn) -> bool + no_proxy = getproxies().get("no") + if not no_proxy: + return False + for host in no_proxy.split(","): + host = host.strip() + if parsed_dsn.host.endswith(host) or parsed_dsn.netloc.endswith(host): + return True + return False + + def _make_pool(self): + # type: (Self) -> Union[PoolManager, ProxyManager, httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] + raise NotImplementedError() + + def _request( + self, + method, + endpoint_type, + body, + headers, + ): + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] + raise NotImplementedError() + + def capture_envelope( + self, envelope # type: Envelope + ): + # type: (...) -> None + def send_envelope_wrapper(): + # type: () -> None + with capture_internal_exceptions(): + self._send_envelope(envelope) + self._flush_client_reports() + + if not self._worker.submit(send_envelope_wrapper): + self.on_dropped_event("full_queue") + for item in envelope.items: + self.record_lost_event("queue_overflow", item=item) + + def flush( + self, + timeout, + callback=None, + ): + # type: (Self, float, Optional[Callable[[int, float], None]]) -> None + logger.debug("Flushing HTTP transport") + + if timeout > 0: + self._worker.submit(lambda: self._flush_client_reports(force=True)) + self._worker.flush(timeout, callback) + + def kill(self): + # type: (Self) -> None + logger.debug("Killing HTTP transport") + self._worker.kill() + + @staticmethod + def _warn_hub_cls(): + # type: () -> None + """Convenience method to warn users about the deprecation of the `hub_cls` attribute.""" + warnings.warn( + "The `hub_cls` attribute is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=3, + ) + + @property + def hub_cls(self): + # type: (Self) -> type[sentry_sdk.Hub] + """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" + HttpTransport._warn_hub_cls() + return self._hub_cls + + @hub_cls.setter + def hub_cls(self, value): + # type: (Self, type[sentry_sdk.Hub]) -> None + """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" + HttpTransport._warn_hub_cls() + self._hub_cls = value + + +class HttpTransport(BaseHttpTransport): + if TYPE_CHECKING: + _pool: Union[PoolManager, ProxyManager] + + def _get_pool_options(self): + # type: (Self) -> Dict[str, Any] + + num_pools = self.options.get("_experiments", {}).get("transport_num_pools") options = { - "num_pools": self._num_pools, + "num_pools": 2 if num_pools is None else int(num_pools), "cert_reqs": "CERT_REQUIRED", } @@ -501,60 +641,50 @@ def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): options["socket_options"] = socket_options options["ca_certs"] = ( - ca_certs # User-provided bundle from the SDK init + self.options["ca_certs"] # User-provided bundle from the SDK init or os.environ.get("SSL_CERT_FILE") or os.environ.get("REQUESTS_CA_BUNDLE") or certifi.where() ) - options["cert_file"] = cert_file or os.environ.get("CLIENT_CERT_FILE") - options["key_file"] = key_file or os.environ.get("CLIENT_KEY_FILE") + options["cert_file"] = self.options["cert_file"] or os.environ.get( + "CLIENT_CERT_FILE" + ) + options["key_file"] = self.options["key_file"] or os.environ.get( + "CLIENT_KEY_FILE" + ) return options - def _in_no_proxy(self, parsed_dsn): - # type: (Dsn) -> bool - no_proxy = getproxies().get("no") - if not no_proxy: - return False - for host in no_proxy.split(","): - host = host.strip() - if parsed_dsn.host.endswith(host) or parsed_dsn.netloc.endswith(host): - return True - return False + def _make_pool(self): + # type: (Self) -> Union[PoolManager, ProxyManager] + if self.parsed_dsn is None: + raise ValueError("Cannot create HTTP-based transport without valid DSN") - def _make_pool( - self, - parsed_dsn, # type: Dsn - http_proxy, # type: Optional[str] - https_proxy, # type: Optional[str] - ca_certs, # type: Optional[Any] - cert_file, # type: Optional[Any] - key_file, # type: Optional[Any] - proxy_headers, # type: Optional[Dict[str, str]] - ): - # type: (...) -> Union[PoolManager, ProxyManager] proxy = None - no_proxy = self._in_no_proxy(parsed_dsn) + no_proxy = self._in_no_proxy(self.parsed_dsn) # try HTTPS first - if parsed_dsn.scheme == "https" and (https_proxy != ""): + https_proxy = self.options["https_proxy"] + if self.parsed_dsn.scheme == "https" and (https_proxy != ""): proxy = https_proxy or (not no_proxy and getproxies().get("https")) # maybe fallback to HTTP proxy + http_proxy = self.options["http_proxy"] if not proxy and (http_proxy != ""): proxy = http_proxy or (not no_proxy and getproxies().get("http")) - opts = self._get_pool_options(ca_certs, cert_file, key_file) + opts = self._get_pool_options() if proxy: + proxy_headers = self.options["proxy_headers"] if proxy_headers: opts["proxy_headers"] = proxy_headers if proxy.startswith("socks"): use_socks_proxy = True try: - # Check if PySocks depencency is available + # Check if PySocks dependency is available from urllib3.contrib.socks import SOCKSProxyManager except ImportError: use_socks_proxy = False @@ -572,61 +702,151 @@ def _make_pool( else: return urllib3.PoolManager(**opts) - def capture_envelope( - self, envelope # type: Envelope + def _request( + self, + method, + endpoint_type, + body, + headers, ): - # type: (...) -> None - def send_envelope_wrapper(): - # type: () -> None - with capture_internal_exceptions(): - self._send_envelope(envelope) - self._flush_client_reports() + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse + return self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + body=body, + headers=headers, + ) - if not self._worker.submit(send_envelope_wrapper): - self.on_dropped_event("full_queue") - for item in envelope.items: - self.record_lost_event("queue_overflow", item=item) - def flush( - self, - timeout, # type: float - callback=None, # type: Optional[Any] - ): - # type: (...) -> None - logger.debug("Flushing HTTP transport") +try: + import httpcore + import h2 # type: ignore # noqa: F401 +except ImportError: + # Sorry, no Http2Transport for you + class Http2Transport(HttpTransport): + def __init__(self, options): + # type: (Self, Dict[str, Any]) -> None + super().__init__(options) + logger.warning( + "You tried to use HTTP2Transport but don't have httpcore[http2] installed. Falling back to HTTPTransport." + ) - if timeout > 0: - self._worker.submit(lambda: self._flush_client_reports(force=True)) - self._worker.flush(timeout, callback) +else: + + class Http2Transport(BaseHttpTransport): # type: ignore + """The HTTP2 transport based on httpcore.""" + + if TYPE_CHECKING: + _pool: Union[ + httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool + ] + + def _get_header_value(self, response, header): + # type: (Self, httpcore.Response, str) -> Optional[str] + return next( + ( + val.decode("ascii") + for key, val in response.headers + if key.decode("ascii").lower() == header + ), + None, + ) - def kill(self): - # type: () -> None - logger.debug("Killing HTTP transport") - self._worker.kill() + def _request( + self, + method, + endpoint_type, + body, + headers, + ): + # type: (Self, str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response + response = self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + content=body, + headers=headers, # type: ignore + ) + return response + + def _get_pool_options(self): + # type: (Self) -> Dict[str, Any] + options = { + "http2": self.parsed_dsn is not None + and self.parsed_dsn.scheme == "https", + "retries": 3, + } # type: Dict[str, Any] + + socket_options = ( + self.options["socket_options"] + if self.options["socket_options"] is not None + else [] + ) - @staticmethod - def _warn_hub_cls(): - # type: () -> None - """Convenience method to warn users about the deprecation of the `hub_cls` attribute.""" - warnings.warn( - "The `hub_cls` attribute is deprecated and will be removed in a future release.", - DeprecationWarning, - stacklevel=3, - ) + used_options = {(o[0], o[1]) for o in socket_options} + for default_option in KEEP_ALIVE_SOCKET_OPTIONS: + if (default_option[0], default_option[1]) not in used_options: + socket_options.append(default_option) - @property - def hub_cls(self): - # type: () -> type[sentry_sdk.Hub] - """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" - HttpTransport._warn_hub_cls() - return self._hub_cls + options["socket_options"] = socket_options - @hub_cls.setter - def hub_cls(self, value): - # type: (type[sentry_sdk.Hub]) -> None - """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" - HttpTransport._warn_hub_cls() - self._hub_cls = value + ssl_context = ssl.create_default_context() + ssl_context.load_verify_locations( + self.options["ca_certs"] # User-provided bundle from the SDK init + or os.environ.get("SSL_CERT_FILE") + or os.environ.get("REQUESTS_CA_BUNDLE") + or certifi.where() + ) + cert_file = self.options["cert_file"] or os.environ.get("CLIENT_CERT_FILE") + key_file = self.options["key_file"] or os.environ.get("CLIENT_KEY_FILE") + if cert_file is not None: + ssl_context.load_cert_chain(cert_file, key_file) + + options["ssl_context"] = ssl_context + + return options + + def _make_pool(self): + # type: (Self) -> Union[httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] + if self.parsed_dsn is None: + raise ValueError("Cannot create HTTP-based transport without valid DSN") + proxy = None + no_proxy = self._in_no_proxy(self.parsed_dsn) + + # try HTTPS first + https_proxy = self.options["https_proxy"] + if self.parsed_dsn.scheme == "https" and (https_proxy != ""): + proxy = https_proxy or (not no_proxy and getproxies().get("https")) + + # maybe fallback to HTTP proxy + http_proxy = self.options["http_proxy"] + if not proxy and (http_proxy != ""): + proxy = http_proxy or (not no_proxy and getproxies().get("http")) + + opts = self._get_pool_options() + + if proxy: + proxy_headers = self.options["proxy_headers"] + if proxy_headers: + opts["proxy_headers"] = proxy_headers + + if proxy.startswith("socks"): + try: + if "socket_options" in opts: + socket_options = opts.pop("socket_options") + if socket_options: + logger.warning( + "You have defined socket_options but using a SOCKS proxy which doesn't support these. We'll ignore socket_options." + ) + return httpcore.SOCKSProxy(proxy_url=proxy, **opts) + except RuntimeError: + logger.warning( + "You have configured a SOCKS proxy (%s) but support for SOCKS proxies is not installed. Disabling proxy support.", + proxy, + ) + else: + return httpcore.HTTPProxy(proxy_url=proxy, **opts) + + return httpcore.ConnectionPool(**opts) class _FunctionTransport(Transport): @@ -662,8 +882,12 @@ def make_transport(options): # type: (Dict[str, Any]) -> Optional[Transport] ref_transport = options["transport"] + use_http2_transport = options.get("_experiments", {}).get("transport_http2", False) + # By default, we use the http transport class - transport_cls = HttpTransport # type: Type[Transport] + transport_cls = ( + Http2Transport if use_http2_transport else HttpTransport + ) # type: Type[Transport] if isinstance(ref_transport, Transport): return ref_transport diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 08d2768cde..ae6e7538ac 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -11,7 +11,7 @@ import threading import time from collections import namedtuple -from datetime import datetime +from datetime import datetime, timezone from decimal import Decimal from functools import partial, partialmethod, wraps from numbers import Real @@ -26,12 +26,16 @@ import sentry_sdk from sentry_sdk._compat import PY37 -from sentry_sdk._types import TYPE_CHECKING -from sentry_sdk.consts import DEFAULT_MAX_VALUE_LENGTH, EndpointType +from sentry_sdk.consts import ( + DEFAULT_ADD_FULL_STACK, + DEFAULT_MAX_STACK_FRAMES, + DEFAULT_MAX_VALUE_LENGTH, + EndpointType, +) -if TYPE_CHECKING: - from collections.abc import Awaitable +from typing import TYPE_CHECKING +if TYPE_CHECKING: from types import FrameType, TracebackType from typing import ( Any, @@ -71,6 +75,25 @@ SENSITIVE_DATA_SUBSTITUTE = "[Filtered]" +FALSY_ENV_VALUES = frozenset(("false", "f", "n", "no", "off", "0")) +TRUTHY_ENV_VALUES = frozenset(("true", "t", "y", "yes", "on", "1")) + + +def env_to_bool(value, *, strict=False): + # type: (Any, Optional[bool]) -> bool | None + """Casts an ENV variable value to boolean using the constants defined above. + In strict mode, it may return None if the value doesn't match any of the predefined values. + """ + normalized = str(value).lower() if value is not None else None + + if normalized in FALSY_ENV_VALUES: + return False + + if normalized in TRUTHY_ENV_VALUES: + return True + + return None if strict else bool(value) + def json_dumps(data): # type: (Any) -> bytes @@ -208,7 +231,40 @@ def to_timestamp(value): def format_timestamp(value): # type: (datetime) -> str - return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + """Formats a timestamp in RFC 3339 format. + + Any datetime objects with a non-UTC timezone are converted to UTC, so that all timestamps are formatted in UTC. + """ + utctime = value.astimezone(timezone.utc) + + # We use this custom formatting rather than isoformat for backwards compatibility (we have used this format for + # several years now), and isoformat is slightly different. + return utctime.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + + +ISO_TZ_SEPARATORS = frozenset(("+", "-")) + + +def datetime_from_isoformat(value): + # type: (str) -> datetime + try: + result = datetime.fromisoformat(value) + except (AttributeError, ValueError): + # py 3.6 + timestamp_format = ( + "%Y-%m-%dT%H:%M:%S.%f" if "." in value else "%Y-%m-%dT%H:%M:%S" + ) + if value.endswith("Z"): + value = value[:-1] + "+0000" + + if value[-6] in ISO_TZ_SEPARATORS: + timestamp_format += "%z" + value = value[:-3] + value[-2:] + elif value[-5] in ISO_TZ_SEPARATORS: + timestamp_format += "%z" + + result = datetime.strptime(value, timestamp_format) + return result.astimezone(timezone.utc) def event_hint_with_exc_info(exc_info=None): @@ -585,8 +641,9 @@ def serialize_frame( include_local_variables=True, include_source_context=True, max_value_length=None, + custom_repr=None, ): - # type: (FrameType, Optional[int], bool, bool, Optional[int]) -> Dict[str, Any] + # type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any] f_code = getattr(frame, "f_code", None) if not f_code: abs_path = None @@ -616,7 +673,11 @@ def serialize_frame( ) if include_local_variables: - rv["vars"] = frame.f_locals.copy() + from sentry_sdk.serializer import serialize + + rv["vars"] = serialize( + dict(frame.f_locals), is_vars=True, custom_repr=custom_repr + ) return rv @@ -655,11 +716,21 @@ def get_errno(exc_value): def get_error_message(exc_value): # type: (Optional[BaseException]) -> str - return ( + message = ( getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value) - ) + ) # type: str + + # __notes__ should be a list of strings when notes are added + # via add_note, but can be anything else if __notes__ is set + # directly. We only support strings in __notes__, since that + # is the correct use. + notes = getattr(exc_value, "__notes__", None) # type: object + if isinstance(notes, list) and len(notes) > 0: + message += "\n" + "\n".join(note for note in notes if isinstance(note, str)) + + return message def single_exception_from_error_tuple( @@ -671,6 +742,7 @@ def single_exception_from_error_tuple( exception_id=None, # type: Optional[int] parent_id=None, # type: Optional[int] source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Dict[str, Any] """ @@ -721,10 +793,12 @@ def single_exception_from_error_tuple( include_local_variables = True include_source_context = True max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback + custom_repr = None else: include_local_variables = client_options["include_local_variables"] include_source_context = client_options["include_source_context"] max_value_length = client_options["max_value_length"] + custom_repr = client_options.get("custom_repr") frames = [ serialize_frame( @@ -733,12 +807,18 @@ def single_exception_from_error_tuple( include_local_variables=include_local_variables, include_source_context=include_source_context, max_value_length=max_value_length, + custom_repr=custom_repr, ) for tb in iter_stacks(tb) - ] + ] # type: List[Dict[str, Any]] if frames: - exception_value["stacktrace"] = {"frames": frames} + if not full_stack: + new_frames = frames + else: + new_frames = merge_stack_frames(frames, full_stack, client_options) + + exception_value["stacktrace"] = {"frames": new_frames} return exception_value @@ -793,6 +873,7 @@ def exceptions_from_error( exception_id=0, # type: int parent_id=0, # type: int source=None, # type: Optional[str] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> Tuple[int, List[Dict[str, Any]]] """ @@ -812,6 +893,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source=source, + full_stack=full_stack, ) exceptions = [parent] @@ -837,6 +919,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__cause__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -858,6 +941,7 @@ def exceptions_from_error( mechanism=mechanism, exception_id=exception_id, source="__context__", + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -874,6 +958,7 @@ def exceptions_from_error( exception_id=exception_id, parent_id=parent_id, source="exceptions[%s]" % idx, + full_stack=full_stack, ) exceptions.extend(child_exceptions) @@ -884,6 +969,7 @@ def exceptions_from_error_tuple( exc_info, # type: ExcInfo client_options=None, # type: Optional[Dict[str, Any]] mechanism=None, # type: Optional[Dict[str, Any]] + full_stack=None, # type: Optional[list[dict[str, Any]]] ): # type: (...) -> List[Dict[str, Any]] exc_type, exc_value, tb = exc_info @@ -901,6 +987,7 @@ def exceptions_from_error_tuple( mechanism=mechanism, exception_id=0, parent_id=0, + full_stack=full_stack, ) else: @@ -908,7 +995,12 @@ def exceptions_from_error_tuple( for exc_type, exc_value, tb in walk_exception_chain(exc_info): exceptions.append( single_exception_from_error_tuple( - exc_type, exc_value, tb, client_options, mechanism + exc_type=exc_type, + exc_value=exc_value, + tb=tb, + client_options=client_options, + mechanism=mechanism, + full_stack=full_stack, ) ) @@ -1027,6 +1119,46 @@ def exc_info_from_error(error): return exc_info +def merge_stack_frames(frames, full_stack, client_options): + # type: (List[Dict[str, Any]], List[Dict[str, Any]], Optional[Dict[str, Any]]) -> List[Dict[str, Any]] + """ + Add the missing frames from full_stack to frames and return the merged list. + """ + frame_ids = { + ( + frame["abs_path"], + frame["context_line"], + frame["lineno"], + frame["function"], + ) + for frame in frames + } + + new_frames = [ + stackframe + for stackframe in full_stack + if ( + stackframe["abs_path"], + stackframe["context_line"], + stackframe["lineno"], + stackframe["function"], + ) + not in frame_ids + ] + new_frames.extend(frames) + + # Limit the number of frames + max_stack_frames = ( + client_options.get("max_stack_frames", DEFAULT_MAX_STACK_FRAMES) + if client_options + else None + ) + if max_stack_frames is not None: + new_frames = new_frames[len(new_frames) - max_stack_frames :] + + return new_frames + + def event_from_exception( exc_info, # type: Union[BaseException, ExcInfo] client_options=None, # type: Optional[Dict[str, Any]] @@ -1035,12 +1167,21 @@ def event_from_exception( # type: (...) -> Tuple[Event, Dict[str, Any]] exc_info = exc_info_from_error(exc_info) hint = event_hint_with_exc_info(exc_info) + + if client_options and client_options.get("add_full_stack", DEFAULT_ADD_FULL_STACK): + full_stack = current_stacktrace( + include_local_variables=client_options["include_local_variables"], + max_value_length=client_options["max_value_length"], + )["frames"] + else: + full_stack = None + return ( { "level": "error", "exception": { "values": exceptions_from_error_tuple( - exc_info, client_options, mechanism + exc_info, client_options, mechanism, full_stack ) }, }, @@ -1049,7 +1190,7 @@ def event_from_exception( def _module_in_list(name, items): - # type: (str, Optional[List[str]]) -> bool + # type: (Optional[str], Optional[List[str]]) -> bool if name is None: return False @@ -1064,8 +1205,11 @@ def _module_in_list(name, items): def _is_external_source(abs_path): - # type: (str) -> bool + # type: (Optional[str]) -> bool # check if frame is in 'site-packages' or 'dist-packages' + if abs_path is None: + return False + external_source = ( re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None ) @@ -1073,8 +1217,8 @@ def _is_external_source(abs_path): def _is_in_project_root(abs_path, project_root): - # type: (str, Optional[str]) -> bool - if project_root is None: + # type: (Optional[str], Optional[str]) -> bool + if abs_path is None or project_root is None: return False # check if path is in the project root @@ -1657,12 +1801,6 @@ def _no_op(*_a, **_k): pass -async def _no_op_async(*_a, **_k): - # type: (*Any, **Any) -> None - """No-op function for ensure_integration_enabled_async.""" - pass - - if TYPE_CHECKING: @overload @@ -1729,59 +1867,6 @@ def runner(*args: "P.args", **kwargs: "P.kwargs"): return patcher -if TYPE_CHECKING: - - # mypy has some trouble with the overloads, hence the ignore[no-overload-impl] - @overload # type: ignore[no-overload-impl] - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - original_function, # type: Callable[P, Awaitable[R]] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - ... - - @overload - def ensure_integration_enabled_async( - integration, # type: type[sentry_sdk.integrations.Integration] - ): - # type: (...) -> Callable[[Callable[P, Awaitable[None]]], Callable[P, Awaitable[None]]] - ... - - -# The ignore[no-redef] also needed because mypy is struggling with these overloads. -def ensure_integration_enabled_async( # type: ignore[no-redef] - integration, # type: type[sentry_sdk.integrations.Integration] - original_function=_no_op_async, # type: Union[Callable[P, Awaitable[R]], Callable[P, Awaitable[None]]] -): - # type: (...) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]] - """ - Version of `ensure_integration_enabled` for decorating async functions. - - Please refer to the `ensure_integration_enabled` documentation for more information. - """ - - if TYPE_CHECKING: - # Type hint to ensure the default function has the right typing. The overloads - # ensure the default _no_op function is only used when R is None. - original_function = cast(Callable[P, Awaitable[R]], original_function) - - def patcher(sentry_patched_function): - # type: (Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]] - async def runner(*args: "P.args", **kwargs: "P.kwargs"): - # type: (...) -> R - if sentry_sdk.get_client().get_integration(integration) is None: - return await original_function(*args, **kwargs) - - return await sentry_patched_function(*args, **kwargs) - - if original_function is _no_op_async: - return wraps(sentry_patched_function)(runner) - - return wraps(original_function)(runner) - - return patcher - - if PY37: def nanosecond_time(): diff --git a/sentry_sdk/worker.py b/sentry_sdk/worker.py index 2e4c58f46a..b04ea582bc 100644 --- a/sentry_sdk/worker.py +++ b/sentry_sdk/worker.py @@ -6,7 +6,7 @@ from sentry_sdk.utils import logger from sentry_sdk.consts import DEFAULT_QUEUE_SIZE -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any diff --git a/setup.py b/setup.py index 68da68a52b..da3adcab42 100644 --- a/setup.py +++ b/setup.py @@ -21,7 +21,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.12.0", + version="2.19.2", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", @@ -58,16 +58,19 @@ def get_file_text(file_name): "fastapi": ["fastapi>=0.79.0"], "flask": ["flask>=0.11", "blinker>=1.1", "markupsafe"], "grpcio": ["grpcio>=1.21.1", "protobuf>=3.8.0"], + "http2": ["httpcore[http2]==1.*"], "httpx": ["httpx>=0.16.0"], "huey": ["huey>=2"], "huggingface_hub": ["huggingface_hub>=0.22"], "langchain": ["langchain>=0.0.210"], + "launchdarkly": ["launchdarkly-server-sdk>=9.8.0"], "litestar": ["litestar>=2.0.0"], "loguru": ["loguru>=0.5"], "openai": ["openai>=1.0.0", "tiktoken>=0.3.0"], + "openfeature": ["openfeature-sdk>=0.7.1"], "opentelemetry": ["opentelemetry-distro>=0.35b0"], "opentelemetry-experimental": ["opentelemetry-distro"], - "pure_eval": ["pure_eval", "executing", "asttokens"], + "pure-eval": ["pure_eval", "executing", "asttokens"], "pymongo": ["pymongo>=3.1"], "pyspark": ["pyspark>=2.4.4"], "quart": ["quart>=0.16.1", "blinker>=1.1"], @@ -98,6 +101,7 @@ def get_file_text(file_name): "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries :: Python Modules", ], options={"bdist_wheel": {"universal": "1"}}, diff --git a/tests/conftest.py b/tests/conftest.py index c31a394fb5..c0383d94b7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,7 @@ from tests import _warning_recorder, _warning_recorder_mgr -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Optional @@ -184,6 +184,17 @@ def reset_integrations(): _installed_integrations.clear() +@pytest.fixture +def uninstall_integration(): + """Use to force the next call to sentry_init to re-install/setup an integration.""" + + def inner(identifier): + _processed_integrations.discard(identifier) + _installed_integrations.discard(identifier) + + return inner + + @pytest.fixture def sentry_init(request): def inner(*a, **kw): diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 43e3bec546..cd65e7cdd5 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -7,6 +7,13 @@ from aiohttp import web, ClientSession from aiohttp.client import ServerDisconnectedError from aiohttp.web_request import Request +from aiohttp.web_exceptions import ( + HTTPInternalServerError, + HTTPNetworkAuthenticationRequired, + HTTPBadRequest, + HTTPNotFound, + HTTPUnavailableForLegalReasons, +) from sentry_sdk import capture_message, start_transaction from sentry_sdk.integrations.aiohttp import AioHttpIntegration @@ -48,7 +55,7 @@ async def hello(request): assert request["url"] == "http://{host}/".format(host=host) assert request["headers"] == { "Accept": "*/*", - "Accept-Encoding": "gzip, deflate", + "Accept-Encoding": mock.ANY, "Host": host, "User-Agent": request["headers"]["User-Agent"], "baggage": mock.ANY, @@ -596,3 +603,118 @@ async def hello(request): (event,) = events assert event["contexts"]["trace"]["origin"] == "auto.http.aiohttp" assert event["spans"][0]["origin"] == "auto.http.aiohttp" + + +@pytest.mark.parametrize( + ("integration_kwargs", "exception_to_raise", "should_capture"), + ( + ({}, None, False), + ({}, HTTPBadRequest, False), + ( + {}, + HTTPUnavailableForLegalReasons(None), + False, + ), # Highest 4xx status code (451) + ({}, HTTPInternalServerError, True), + ({}, HTTPNetworkAuthenticationRequired, True), # Highest 5xx status code (511) + ({"failed_request_status_codes": set()}, HTTPInternalServerError, False), + ( + {"failed_request_status_codes": set()}, + HTTPNetworkAuthenticationRequired, + False, + ), + ({"failed_request_status_codes": {404, *range(500, 600)}}, HTTPNotFound, True), + ( + {"failed_request_status_codes": {404, *range(500, 600)}}, + HTTPInternalServerError, + True, + ), + ( + {"failed_request_status_codes": {404, *range(500, 600)}}, + HTTPBadRequest, + False, + ), + ), +) +@pytest.mark.asyncio +async def test_failed_request_status_codes( + sentry_init, + aiohttp_client, + capture_events, + integration_kwargs, + exception_to_raise, + should_capture, +): + sentry_init(integrations=[AioHttpIntegration(**integration_kwargs)]) + events = capture_events() + + async def handle(_): + if exception_to_raise is not None: + raise exception_to_raise + else: + return web.Response(status=200) + + app = web.Application() + app.router.add_get("/", handle) + + client = await aiohttp_client(app) + resp = await client.get("/") + + expected_status = ( + 200 if exception_to_raise is None else exception_to_raise.status_code + ) + assert resp.status == expected_status + + if should_capture: + (event,) = events + assert event["exception"]["values"][0]["type"] == exception_to_raise.__name__ + else: + assert not events + + +@pytest.mark.asyncio +async def test_failed_request_status_codes_with_returned_status( + sentry_init, aiohttp_client, capture_events +): + """ + Returning a web.Response with a failed_request_status_code should not be reported to Sentry. + """ + sentry_init(integrations=[AioHttpIntegration(failed_request_status_codes={500})]) + events = capture_events() + + async def handle(_): + return web.Response(status=500) + + app = web.Application() + app.router.add_get("/", handle) + + client = await aiohttp_client(app) + resp = await client.get("/") + + assert resp.status == 500 + assert not events + + +@pytest.mark.asyncio +async def test_failed_request_status_codes_non_http_exception( + sentry_init, aiohttp_client, capture_events +): + """ + If an exception, which is not an instance of HTTPException, is raised, it should be captured, even if + failed_request_status_codes is empty. + """ + sentry_init(integrations=[AioHttpIntegration(failed_request_status_codes=set())]) + events = capture_events() + + async def handle(_): + 1 / 0 + + app = web.Application() + app.router.add_get("/", handle) + + client = await aiohttp_client(app) + resp = await client.get("/") + assert resp.status == 500 + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ZeroDivisionError" diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index 5fefde9b5a..8ce12e70f5 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -1,17 +1,38 @@ -import pytest from unittest import mock -from anthropic import Anthropic, Stream, AnthropicError -from anthropic.types import Usage, MessageDeltaUsage, TextDelta + +try: + from unittest.mock import AsyncMock +except ImportError: + + class AsyncMock(mock.MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + + +import pytest +from anthropic import AsyncAnthropic, Anthropic, AnthropicError, AsyncStream, Stream +from anthropic.types import MessageDeltaUsage, TextDelta, Usage +from anthropic.types.content_block_delta_event import ContentBlockDeltaEvent +from anthropic.types.content_block_start_event import ContentBlockStartEvent +from anthropic.types.content_block_stop_event import ContentBlockStopEvent from anthropic.types.message import Message from anthropic.types.message_delta_event import MessageDeltaEvent from anthropic.types.message_start_event import MessageStartEvent -from anthropic.types.content_block_start_event import ContentBlockStartEvent -from anthropic.types.content_block_delta_event import ContentBlockDeltaEvent -from anthropic.types.content_block_stop_event import ContentBlockStopEvent + +from sentry_sdk.utils import package_version + +try: + from anthropic.types import InputJSONDelta +except ImportError: + try: + from anthropic.types import InputJsonDelta as InputJSONDelta + except ImportError: + pass try: # 0.27+ from anthropic.types.raw_message_delta_event import Delta + from anthropic.types.tool_use_block import ToolUseBlock except ImportError: # pre 0.27 from anthropic.types.message_delta_event import Delta @@ -25,7 +46,7 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.anthropic import AnthropicIntegration - +ANTHROPIC_VERSION = package_version("anthropic") EXAMPLE_MESSAGE = Message( id="id", model="model", @@ -36,6 +57,11 @@ ) +async def async_iterator(values): + for value in values: + yield value + + @pytest.mark.parametrize( "send_default_pii, include_prompts", [ @@ -103,6 +129,74 @@ def test_nonstreaming_create_message( assert span["data"]["ai.streaming"] is False +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_nonstreaming_create_message_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[AnthropicIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + client = AsyncAnthropic(api_key="z") + client.messages._post = AsyncMock(return_value=EXAMPLE_MESSAGE) + + messages = [ + { + "role": "user", + "content": "Hello, Claude", + } + ] + + with start_transaction(name="anthropic"): + response = await client.messages.create( + max_tokens=1024, messages=messages, model="model" + ) + + assert response == EXAMPLE_MESSAGE + usage = response.usage + + assert usage.input_tokens == 10 + assert usage.output_tokens == 20 + + assert len(events) == 1 + (event,) = events + + assert event["type"] == "transaction" + assert event["transaction"] == "anthropic" + + assert len(event["spans"]) == 1 + (span,) = event["spans"] + + assert span["op"] == OP.ANTHROPIC_MESSAGES_CREATE + assert span["description"] == "Anthropic messages create" + assert span["data"][SPANDATA.AI_MODEL_ID] == "model" + + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.AI_INPUT_MESSAGES] == messages + assert span["data"][SPANDATA.AI_RESPONSES] == [ + {"type": "text", "text": "Hi, I'm Claude."} + ] + else: + assert SPANDATA.AI_INPUT_MESSAGES not in span["data"] + assert SPANDATA.AI_RESPONSES not in span["data"] + + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 10 + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 20 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 30 + assert span["data"]["ai.streaming"] is False + + @pytest.mark.parametrize( "send_default_pii, include_prompts", [ @@ -203,6 +297,376 @@ def test_streaming_create_message( assert span["data"]["ai.streaming"] is True +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_streaming_create_message_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + client = AsyncAnthropic(api_key="z") + returned_stream = AsyncStream(cast_to=None, response=None, client=client) + returned_stream._iterator = async_iterator( + [ + MessageStartEvent( + message=EXAMPLE_MESSAGE, + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=TextBlock(type="text", text=""), + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="Hi", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="!", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=TextDelta(text=" I'm Claude!", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockStopEvent(type="content_block_stop", index=0), + MessageDeltaEvent( + delta=Delta(), + usage=MessageDeltaUsage(output_tokens=10), + type="message_delta", + ), + ] + ) + + sentry_init( + integrations=[AnthropicIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + client.messages._post = AsyncMock(return_value=returned_stream) + + messages = [ + { + "role": "user", + "content": "Hello, Claude", + } + ] + + with start_transaction(name="anthropic"): + message = await client.messages.create( + max_tokens=1024, messages=messages, model="model", stream=True + ) + + async for _ in message: + pass + + assert message == returned_stream + assert len(events) == 1 + (event,) = events + + assert event["type"] == "transaction" + assert event["transaction"] == "anthropic" + + assert len(event["spans"]) == 1 + (span,) = event["spans"] + + assert span["op"] == OP.ANTHROPIC_MESSAGES_CREATE + assert span["description"] == "Anthropic messages create" + assert span["data"][SPANDATA.AI_MODEL_ID] == "model" + + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.AI_INPUT_MESSAGES] == messages + assert span["data"][SPANDATA.AI_RESPONSES] == [ + {"type": "text", "text": "Hi! I'm Claude!"} + ] + + else: + assert SPANDATA.AI_INPUT_MESSAGES not in span["data"] + assert SPANDATA.AI_RESPONSES not in span["data"] + + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 10 + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 30 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 40 + assert span["data"]["ai.streaming"] is True + + +@pytest.mark.skipif( + ANTHROPIC_VERSION < (0, 27), + reason="Versions <0.27.0 do not include InputJSONDelta, which was introduced in >=0.27.0 along with a new message delta type for tool calling.", +) +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +def test_streaming_create_message_with_input_json_delta( + sentry_init, capture_events, send_default_pii, include_prompts +): + client = Anthropic(api_key="z") + returned_stream = Stream(cast_to=None, response=None, client=client) + returned_stream._iterator = [ + MessageStartEvent( + message=Message( + id="msg_0", + content=[], + model="claude-3-5-sonnet-20240620", + role="assistant", + stop_reason=None, + stop_sequence=None, + type="message", + usage=Usage(input_tokens=366, output_tokens=10), + ), + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=ToolUseBlock( + id="toolu_0", input={}, name="get_weather", type="tool_use" + ), + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="{'location':", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json=" 'S", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="an ", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="Francisco, C", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="A'}", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockStopEvent(type="content_block_stop", index=0), + MessageDeltaEvent( + delta=Delta(stop_reason="tool_use", stop_sequence=None), + usage=MessageDeltaUsage(output_tokens=41), + type="message_delta", + ), + ] + + sentry_init( + integrations=[AnthropicIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + client.messages._post = mock.Mock(return_value=returned_stream) + + messages = [ + { + "role": "user", + "content": "What is the weather like in San Francisco?", + } + ] + + with start_transaction(name="anthropic"): + message = client.messages.create( + max_tokens=1024, messages=messages, model="model", stream=True + ) + + for _ in message: + pass + + assert message == returned_stream + assert len(events) == 1 + (event,) = events + + assert event["type"] == "transaction" + assert event["transaction"] == "anthropic" + + assert len(event["spans"]) == 1 + (span,) = event["spans"] + + assert span["op"] == OP.ANTHROPIC_MESSAGES_CREATE + assert span["description"] == "Anthropic messages create" + assert span["data"][SPANDATA.AI_MODEL_ID] == "model" + + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.AI_INPUT_MESSAGES] == messages + assert span["data"][SPANDATA.AI_RESPONSES] == [ + {"text": "", "type": "text"} + ] # we do not record InputJSONDelta because it could contain PII + + else: + assert SPANDATA.AI_INPUT_MESSAGES not in span["data"] + assert SPANDATA.AI_RESPONSES not in span["data"] + + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 366 + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 51 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 417 + assert span["data"]["ai.streaming"] is True + + +@pytest.mark.asyncio +@pytest.mark.skipif( + ANTHROPIC_VERSION < (0, 27), + reason="Versions <0.27.0 do not include InputJSONDelta, which was introduced in >=0.27.0 along with a new message delta type for tool calling.", +) +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_streaming_create_message_with_input_json_delta_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + client = AsyncAnthropic(api_key="z") + returned_stream = AsyncStream(cast_to=None, response=None, client=client) + returned_stream._iterator = async_iterator( + [ + MessageStartEvent( + message=Message( + id="msg_0", + content=[], + model="claude-3-5-sonnet-20240620", + role="assistant", + stop_reason=None, + stop_sequence=None, + type="message", + usage=Usage(input_tokens=366, output_tokens=10), + ), + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=ToolUseBlock( + id="toolu_0", input={}, name="get_weather", type="tool_use" + ), + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta( + partial_json="{'location':", type="input_json_delta" + ), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json=" 'S", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="an ", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta( + partial_json="Francisco, C", type="input_json_delta" + ), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=InputJSONDelta(partial_json="A'}", type="input_json_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockStopEvent(type="content_block_stop", index=0), + MessageDeltaEvent( + delta=Delta(stop_reason="tool_use", stop_sequence=None), + usage=MessageDeltaUsage(output_tokens=41), + type="message_delta", + ), + ] + ) + + sentry_init( + integrations=[AnthropicIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + client.messages._post = AsyncMock(return_value=returned_stream) + + messages = [ + { + "role": "user", + "content": "What is the weather like in San Francisco?", + } + ] + + with start_transaction(name="anthropic"): + message = await client.messages.create( + max_tokens=1024, messages=messages, model="model", stream=True + ) + + async for _ in message: + pass + + assert message == returned_stream + assert len(events) == 1 + (event,) = events + + assert event["type"] == "transaction" + assert event["transaction"] == "anthropic" + + assert len(event["spans"]) == 1 + (span,) = event["spans"] + + assert span["op"] == OP.ANTHROPIC_MESSAGES_CREATE + assert span["description"] == "Anthropic messages create" + assert span["data"][SPANDATA.AI_MODEL_ID] == "model" + + if send_default_pii and include_prompts: + assert span["data"][SPANDATA.AI_INPUT_MESSAGES] == messages + assert span["data"][SPANDATA.AI_RESPONSES] == [ + {"text": "", "type": "text"} + ] # we do not record InputJSONDelta because it could contain PII + + else: + assert SPANDATA.AI_INPUT_MESSAGES not in span["data"] + assert SPANDATA.AI_RESPONSES not in span["data"] + + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 366 + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 51 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 417 + assert span["data"]["ai.streaming"] is True + + def test_exception_message_create(sentry_init, capture_events): sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0) events = capture_events() @@ -222,6 +686,26 @@ def test_exception_message_create(sentry_init, capture_events): assert event["level"] == "error" +@pytest.mark.asyncio +async def test_exception_message_create_async(sentry_init, capture_events): + sentry_init(integrations=[AnthropicIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = AsyncAnthropic(api_key="z") + client.messages._post = AsyncMock( + side_effect=AnthropicError("API rate limit reached") + ) + with pytest.raises(AnthropicError): + await client.messages.create( + model="some-model", + messages=[{"role": "system", "content": "I'm throwing an exception"}], + max_tokens=1024, + ) + + (event,) = events + assert event["level"] == "error" + + def test_span_origin(sentry_init, capture_events): sentry_init( integrations=[AnthropicIntegration()], @@ -246,3 +730,30 @@ def test_span_origin(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.anthropic" + + +@pytest.mark.asyncio +async def test_span_origin_async(sentry_init, capture_events): + sentry_init( + integrations=[AnthropicIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + client = AsyncAnthropic(api_key="z") + client.messages._post = AsyncMock(return_value=EXAMPLE_MESSAGE) + + messages = [ + { + "role": "user", + "content": "Hello, Claude", + } + ] + + with start_transaction(name="anthropic"): + await client.messages.create(max_tokens=1024, messages=messages, model="model") + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + assert event["spans"][0]["origin"] == "auto.ai.anthropic" diff --git a/tests/integrations/arq/test_arq.py b/tests/integrations/arq/test_arq.py index cd4cad67b8..e74395e26c 100644 --- a/tests/integrations/arq/test_arq.py +++ b/tests/integrations/arq/test_arq.py @@ -83,14 +83,65 @@ class WorkerSettings: return inner +@pytest.fixture +def init_arq_with_dict_settings(sentry_init): + def inner( + cls_functions=None, + cls_cron_jobs=None, + kw_functions=None, + kw_cron_jobs=None, + allow_abort_jobs_=False, + ): + cls_functions = cls_functions or [] + cls_cron_jobs = cls_cron_jobs or [] + + kwargs = {} + if kw_functions is not None: + kwargs["functions"] = kw_functions + if kw_cron_jobs is not None: + kwargs["cron_jobs"] = kw_cron_jobs + + sentry_init( + integrations=[ArqIntegration()], + traces_sample_rate=1.0, + send_default_pii=True, + ) + + server = FakeRedis() + pool = ArqRedis(pool_or_conn=server.connection_pool) + + worker_settings = { + "functions": cls_functions, + "cron_jobs": cls_cron_jobs, + "redis_pool": pool, + "allow_abort_jobs": allow_abort_jobs_, + } + + if not worker_settings["functions"]: + del worker_settings["functions"] + if not worker_settings["cron_jobs"]: + del worker_settings["cron_jobs"] + + worker = arq.worker.create_worker(worker_settings, **kwargs) + + return pool, worker + + return inner + + @pytest.mark.asyncio -async def test_job_result(init_arq): +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) +async def test_job_result(init_arq_settings, request): async def increase(ctx, num): return num + 1 + init_fixture_method = request.getfixturevalue(init_arq_settings) + increase.__qualname__ = increase.__name__ - pool, worker = init_arq([increase]) + pool, worker = init_fixture_method([increase]) job = await pool.enqueue_job("increase", 3) @@ -105,14 +156,19 @@ async def increase(ctx, num): @pytest.mark.asyncio -async def test_job_retry(capture_events, init_arq): +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) +async def test_job_retry(capture_events, init_arq_settings, request): async def retry_job(ctx): if ctx["job_try"] < 2: raise arq.worker.Retry + init_fixture_method = request.getfixturevalue(init_arq_settings) + retry_job.__qualname__ = retry_job.__name__ - pool, worker = init_arq([retry_job]) + pool, worker = init_fixture_method([retry_job]) job = await pool.enqueue_job("retry_job") @@ -139,11 +195,18 @@ async def retry_job(ctx): "source", [("cls_functions", "cls_cron_jobs"), ("kw_functions", "kw_cron_jobs")] ) @pytest.mark.parametrize("job_fails", [True, False], ids=["error", "success"]) +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) @pytest.mark.asyncio -async def test_job_transaction(capture_events, init_arq, source, job_fails): +async def test_job_transaction( + capture_events, init_arq_settings, source, job_fails, request +): async def division(_, a, b=0): return a / b + init_fixture_method = request.getfixturevalue(init_arq_settings) + division.__qualname__ = division.__name__ cron_func = async_partial(division, a=1, b=int(not job_fails)) @@ -152,7 +215,9 @@ async def division(_, a, b=0): cron_job = cron(cron_func, minute=0, run_at_startup=True) functions_key, cron_jobs_key = source - pool, worker = init_arq(**{functions_key: [division], cron_jobs_key: [cron_job]}) + pool, worker = init_fixture_method( + **{functions_key: [division], cron_jobs_key: [cron_job]} + ) events = capture_events() @@ -213,12 +278,17 @@ async def division(_, a, b=0): @pytest.mark.parametrize("source", ["cls_functions", "kw_functions"]) +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) @pytest.mark.asyncio -async def test_enqueue_job(capture_events, init_arq, source): +async def test_enqueue_job(capture_events, init_arq_settings, source, request): async def dummy_job(_): pass - pool, _ = init_arq(**{source: [dummy_job]}) + init_fixture_method = request.getfixturevalue(init_arq_settings) + + pool, _ = init_fixture_method(**{source: [dummy_job]}) events = capture_events() @@ -236,13 +306,18 @@ async def dummy_job(_): @pytest.mark.asyncio -async def test_execute_job_without_integration(init_arq): +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) +async def test_execute_job_without_integration(init_arq_settings, request): async def dummy_job(_ctx): pass + init_fixture_method = request.getfixturevalue(init_arq_settings) + dummy_job.__qualname__ = dummy_job.__name__ - pool, worker = init_arq([dummy_job]) + pool, worker = init_fixture_method([dummy_job]) # remove the integration to trigger the edge case get_client().integrations.pop("arq") @@ -254,12 +329,17 @@ async def dummy_job(_ctx): @pytest.mark.parametrize("source", ["cls_functions", "kw_functions"]) +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) @pytest.mark.asyncio -async def test_span_origin_producer(capture_events, init_arq, source): +async def test_span_origin_producer(capture_events, init_arq_settings, source, request): async def dummy_job(_): pass - pool, _ = init_arq(**{source: [dummy_job]}) + init_fixture_method = request.getfixturevalue(init_arq_settings) + + pool, _ = init_fixture_method(**{source: [dummy_job]}) events = capture_events() @@ -272,13 +352,18 @@ async def dummy_job(_): @pytest.mark.asyncio -async def test_span_origin_consumer(capture_events, init_arq): +@pytest.mark.parametrize( + "init_arq_settings", ["init_arq", "init_arq_with_dict_settings"] +) +async def test_span_origin_consumer(capture_events, init_arq_settings, request): async def job(ctx): pass + init_fixture_method = request.getfixturevalue(init_arq_settings) + job.__qualname__ = job.__name__ - pool, worker = init_arq([job]) + pool, worker = init_fixture_method([job]) job = await pool.enqueue_job("retry_job") diff --git a/tests/integrations/asgi/test_asgi.py b/tests/integrations/asgi/test_asgi.py index d5368ddfe1..f3bc7147bf 100644 --- a/tests/integrations/asgi/test_asgi.py +++ b/tests/integrations/asgi/test_asgi.py @@ -126,6 +126,30 @@ async def app(scope, receive, send): return app +@pytest.fixture +def asgi3_custom_transaction_app(): + async def app(scope, receive, send): + sentry_sdk.get_current_scope().set_transaction_name("foobar", source="custom") + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"text/plain"], + ], + } + ) + + await send( + { + "type": "http.response.body", + "body": b"Hello, world!", + } + ) + + return app + + def test_invalid_transaction_style(asgi3_app): with pytest.raises(ValueError) as exp: SentryAsgiMiddleware(asgi3_app, transaction_style="URL") @@ -679,3 +703,20 @@ def dummy_traces_sampler(sampling_context): async with TestClient(app) as client: await client.get(request_url) + + +@pytest.mark.asyncio +async def test_custom_transaction_name( + sentry_init, asgi3_custom_transaction_app, capture_events +): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + app = SentryAsgiMiddleware(asgi3_custom_transaction_app) + + async with TestClient(app) as client: + await client.get("/test") + + (transaction_event,) = events + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "foobar" + assert transaction_event["transaction_info"] == {"source": "custom"} diff --git a/tests/integrations/asyncio/test_asyncio.py b/tests/integrations/asyncio/test_asyncio.py index a7ecd8034a..fb75bfc69b 100644 --- a/tests/integrations/asyncio/test_asyncio.py +++ b/tests/integrations/asyncio/test_asyncio.py @@ -15,8 +15,8 @@ pass # All tests will be skipped with incompatible versions -minimum_python_37 = pytest.mark.skipif( - sys.version_info < (3, 7), reason="Asyncio tests need Python >= 3.7" +minimum_python_38 = pytest.mark.skipif( + sys.version_info < (3, 8), reason="Asyncio tests need Python >= 3.8" ) @@ -38,14 +38,6 @@ async def boom(): 1 / 0 -@pytest.fixture(scope="session") -def event_loop(request): - """Create an instance of the default event loop for each test case.""" - loop = asyncio.get_event_loop_policy().new_event_loop() - yield loop - loop.close() - - def get_sentry_task_factory(mock_get_running_loop): """ Patches (mocked) asyncio and gets the sentry_task_factory. @@ -57,12 +49,11 @@ def get_sentry_task_factory(mock_get_running_loop): return patched_factory -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_create_task( sentry_init, capture_events, - event_loop, ): sentry_init( traces_sample_rate=1.0, @@ -75,11 +66,11 @@ async def test_create_task( events = capture_events() with sentry_sdk.start_transaction(name="test_transaction_for_create_task"): - with sentry_sdk.start_span(op="root", description="not so important"): - tasks = [event_loop.create_task(foo()), event_loop.create_task(bar())] + with sentry_sdk.start_span(op="root", name="not so important"): + tasks = [asyncio.create_task(foo()), asyncio.create_task(bar())] await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) - sentry_sdk.flush() + sentry_sdk.flush() (transaction_event,) = events @@ -101,8 +92,8 @@ async def test_create_task( ) -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_gather( sentry_init, capture_events, @@ -118,10 +109,10 @@ async def test_gather( events = capture_events() with sentry_sdk.start_transaction(name="test_transaction_for_gather"): - with sentry_sdk.start_span(op="root", description="not so important"): + with sentry_sdk.start_span(op="root", name="not so important"): await asyncio.gather(foo(), bar(), return_exceptions=True) - sentry_sdk.flush() + sentry_sdk.flush() (transaction_event,) = events @@ -143,12 +134,11 @@ async def test_gather( ) -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_exception( sentry_init, capture_events, - event_loop, ): sentry_init( traces_sample_rate=1.0, @@ -161,11 +151,11 @@ async def test_exception( events = capture_events() with sentry_sdk.start_transaction(name="test_exception"): - with sentry_sdk.start_span(op="root", description="not so important"): - tasks = [event_loop.create_task(boom()), event_loop.create_task(bar())] + with sentry_sdk.start_span(op="root", name="not so important"): + tasks = [asyncio.create_task(boom()), asyncio.create_task(bar())] await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) - sentry_sdk.flush() + sentry_sdk.flush() (error_event, _) = events @@ -177,8 +167,8 @@ async def test_exception( assert error_event["exception"]["values"][0]["mechanism"]["type"] == "asyncio" -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_task_result(sentry_init): sentry_init( integrations=[ @@ -194,7 +184,7 @@ async def add(a, b): @minimum_python_311 -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="module") async def test_task_with_context(sentry_init): """ Integration test to ensure working context parameter in Python 3.11+ @@ -223,7 +213,7 @@ async def retrieve_value(): assert retrieve_task.result() == "changed value" -@minimum_python_37 +@minimum_python_38 @patch("asyncio.get_running_loop") def test_patch_asyncio(mock_get_running_loop): """ @@ -242,7 +232,7 @@ def test_patch_asyncio(mock_get_running_loop): assert callable(sentry_task_factory) -@minimum_python_37 +@minimum_python_38 @patch("asyncio.get_running_loop") @patch("sentry_sdk.integrations.asyncio.Task") def test_sentry_task_factory_no_factory(MockTask, mock_get_running_loop): # noqa: N803 @@ -271,7 +261,7 @@ def test_sentry_task_factory_no_factory(MockTask, mock_get_running_loop): # noq assert task_kwargs["loop"] == mock_loop -@minimum_python_37 +@minimum_python_38 @patch("asyncio.get_running_loop") def test_sentry_task_factory_with_factory(mock_get_running_loop): mock_loop = mock_get_running_loop.return_value @@ -361,12 +351,11 @@ def test_sentry_task_factory_context_with_factory(mock_get_running_loop): assert task_factory_kwargs["context"] == mock_context -@minimum_python_37 -@pytest.mark.asyncio +@minimum_python_38 +@pytest.mark.asyncio(loop_scope="module") async def test_span_origin( sentry_init, capture_events, - event_loop, ): sentry_init( integrations=[AsyncioIntegration()], @@ -377,11 +366,11 @@ async def test_span_origin( with sentry_sdk.start_transaction(name="something"): tasks = [ - event_loop.create_task(foo()), + asyncio.create_task(foo()), ] await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION) - sentry_sdk.flush() + sentry_sdk.flush() (event,) = events diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py index ffcaf877d7..e229812336 100644 --- a/tests/integrations/aws_lambda/test_aws.py +++ b/tests/integrations/aws_lambda/test_aws.py @@ -36,6 +36,13 @@ import pytest +RUNTIMES_TO_TEST = [ + "python3.8", + "python3.9", + "python3.10", + "python3.11", + "python3.12", +] LAMBDA_PRELUDE = """ from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration, get_lambda_bootstrap @@ -91,7 +98,7 @@ def truncate_data(data): elif key == "cloudwatch logs": for cloudwatch_key in data["extra"]["cloudwatch logs"].keys(): if cloudwatch_key in ["url", "log_group", "log_stream"]: - cleaned_data["extra"].setdefault("cloudwatch logs", {})[cloudwatch_key] = data["extra"]["cloudwatch logs"][cloudwatch_key] + cleaned_data["extra"].setdefault("cloudwatch logs", {})[cloudwatch_key] = data["extra"]["cloudwatch logs"][cloudwatch_key].split("=")[0] if data.get("level") is not None: cleaned_data["level"] = data.get("level") @@ -137,15 +144,7 @@ def lambda_client(): return get_boto_client() -@pytest.fixture( - params=[ - "python3.8", - "python3.9", - "python3.10", - "python3.11", - "python3.12", - ] -) +@pytest.fixture(params=RUNTIMES_TO_TEST) def lambda_runtime(request): return request.param @@ -229,7 +228,7 @@ def test_handler(event, context): assert event["extra"]["lambda"]["function_name"].startswith("test_") logs_url = event["extra"]["cloudwatch logs"]["url"] - assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region=") + assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region") assert not re.search("(=;|=$)", logs_url) assert event["extra"]["cloudwatch logs"]["log_group"].startswith( "/aws/lambda/test_" @@ -318,6 +317,9 @@ def test_handler(event, context): } +@pytest.mark.xfail( + reason="Amazon changed something (2024-10-01) and on Python 3.9+ our SDK can not capture events in the init phase of the Lambda function anymore. We need to fix this somehow." +) def test_init_error(run_lambda_function, lambda_runtime): envelope_items, _ = run_lambda_function( LAMBDA_PRELUDE @@ -331,7 +333,9 @@ def test_init_error(run_lambda_function, lambda_runtime): syntax_check=False, ) - (event,) = envelope_items + # We just take the last one, because it could be that in the output of the Lambda + # invocation there is still the envelope of the previous invocation of the function. + event = envelope_items[-1] assert event["exception"]["values"][0]["value"] == "name 'func' is not defined" @@ -366,7 +370,7 @@ def test_handler(event, context): assert event["extra"]["lambda"]["function_name"].startswith("test_") logs_url = event["extra"]["cloudwatch logs"]["url"] - assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region=") + assert logs_url.startswith("https://console.aws.amazon.com/cloudwatch/home?region") assert not re.search("(=;|=$)", logs_url) assert event["extra"]["cloudwatch logs"]["log_group"].startswith( "/aws/lambda/test_" @@ -458,11 +462,11 @@ def test_handler(event, context): "X-Forwarded-Proto": "https" }, "httpMethod": "GET", - "path": "/path1", + "path": "/1", "queryStringParameters": { - "done": "false" + "done": "f" }, - "dog": "Maisey" + "d": "D1" }, { "headers": { @@ -470,11 +474,11 @@ def test_handler(event, context): "X-Forwarded-Proto": "http" }, "httpMethod": "POST", - "path": "/path2", + "path": "/2", "queryStringParameters": { - "done": "true" + "done": "t" }, - "dog": "Charlie" + "d": "D2" } ] """, @@ -534,9 +538,9 @@ def test_handler(event, context): request_data = { "headers": {"Host": "x1.io", "X-Forwarded-Proto": "https"}, "method": "GET", - "url": "https://x1.io/path1", + "url": "https://x1.io/1", "query_string": { - "done": "false", + "done": "f", }, } else: diff --git a/tests/integrations/beam/test_beam.py b/tests/integrations/beam/test_beam.py index 5235b93031..8c503b4c8c 100644 --- a/tests/integrations/beam/test_beam.py +++ b/tests/integrations/beam/test_beam.py @@ -45,7 +45,7 @@ def process(self): return self.fn() -class B(A, object): +class B(A): def fa(self, x, element=False, another_element=False): if x or (element and not another_element): # print(self.r) diff --git a/tests/integrations/bottle/test_bottle.py b/tests/integrations/bottle/test_bottle.py index c44327cea6..9cc436a229 100644 --- a/tests/integrations/bottle/test_bottle.py +++ b/tests/integrations/bottle/test_bottle.py @@ -3,12 +3,14 @@ import logging from io import BytesIO -from bottle import Bottle, debug as set_debug, abort, redirect +from bottle import Bottle, debug as set_debug, abort, redirect, HTTPResponse from sentry_sdk import capture_message +from sentry_sdk.integrations.bottle import BottleIntegration from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.integrations.logging import LoggingIntegration from werkzeug.test import Client +from werkzeug.wrappers import Response import sentry_sdk.integrations.bottle as bottle_sentry @@ -337,29 +339,6 @@ def index(): assert len(events) == 1 -def test_logging(sentry_init, capture_events, app, get_client): - # ensure that Bottle's logger magic doesn't break ours - sentry_init( - integrations=[ - bottle_sentry.BottleIntegration(), - LoggingIntegration(event_level="ERROR"), - ] - ) - - @app.route("/") - def index(): - app.logger.error("hi") - return "ok" - - events = capture_events() - - client = get_client() - client.get("/") - - (event,) = events - assert event["level"] == "error" - - def test_mount(app, capture_exceptions, capture_events, sentry_init, get_client): sentry_init(integrations=[bottle_sentry.BottleIntegration()]) @@ -387,31 +366,6 @@ def crashing_app(environ, start_response): assert event["exception"]["values"][0]["mechanism"]["handled"] is False -def test_500(sentry_init, capture_events, app, get_client): - sentry_init(integrations=[bottle_sentry.BottleIntegration()]) - - set_debug(False) - app.catchall = True - - @app.route("/") - def index(): - 1 / 0 - - @app.error(500) - def error_handler(err): - capture_message("error_msg") - return "My error" - - events = capture_events() - - client = get_client() - response = client.get("/") - assert response[1] == "500 Internal Server Error" - - _, event = events - assert event["message"] == "error_msg" - - def test_error_in_errorhandler(sentry_init, capture_events, app, get_client): sentry_init(integrations=[bottle_sentry.BottleIntegration()]) @@ -493,3 +447,80 @@ def test_span_origin( (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.bottle" + + +@pytest.mark.parametrize("raise_error", [True, False]) +@pytest.mark.parametrize( + ("integration_kwargs", "status_code", "should_capture"), + ( + ({}, None, False), + ({}, 400, False), + ({}, 451, False), # Highest 4xx status code + ({}, 500, True), + ({}, 511, True), # Highest 5xx status code + ({"failed_request_status_codes": set()}, 500, False), + ({"failed_request_status_codes": set()}, 511, False), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 404, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 500, True), + ({"failed_request_status_codes": {404, *range(500, 600)}}, 400, False), + ), +) +def test_failed_request_status_codes( + sentry_init, + capture_events, + integration_kwargs, + status_code, + should_capture, + raise_error, +): + sentry_init(integrations=[BottleIntegration(**integration_kwargs)]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + if status_code is not None: + response = HTTPResponse(status=status_code) + if raise_error: + raise response + else: + return response + return "OK" + + client = Client(app, Response) + response = client.get("/") + + expected_status = 200 if status_code is None else status_code + assert response.status_code == expected_status + + if should_capture: + (event,) = events + assert event["exception"]["values"][0]["type"] == "HTTPResponse" + else: + assert not events + + +def test_failed_request_status_codes_non_http_exception(sentry_init, capture_events): + """ + If an exception, which is not an instance of HTTPResponse, is raised, it should be captured, even if + failed_request_status_codes is empty. + """ + sentry_init(integrations=[BottleIntegration(failed_request_status_codes=set())]) + events = capture_events() + + app = Bottle() + + @app.route("/") + def handle(): + 1 / 0 + + client = Client(app, Response) + + try: + client.get("/") + except ZeroDivisionError: + pass + + (event,) = events + assert event["exception"]["values"][0]["type"] == "ZeroDivisionError" diff --git a/tests/integrations/celery/test_celery.py b/tests/integrations/celery/test_celery.py index cc0bfd0390..e51341599f 100644 --- a/tests/integrations/celery/test_celery.py +++ b/tests/integrations/celery/test_celery.py @@ -10,7 +10,7 @@ from sentry_sdk import start_transaction, get_current_span from sentry_sdk.integrations.celery import ( CeleryIntegration, - _wrap_apply_async, + _wrap_task_run, ) from sentry_sdk.integrations.celery.beat import _get_headers from tests.conftest import ApproxDict @@ -568,7 +568,7 @@ def dummy_function(*args, **kwargs): assert "sentry-trace" in headers assert "baggage" in headers - wrapped = _wrap_apply_async(dummy_function) + wrapped = _wrap_task_run(dummy_function) wrapped(mock.MagicMock(), (), headers={}) @@ -783,3 +783,59 @@ def task(): ... assert span["origin"] == "auto.queue.celery" monkeypatch.setattr(kombu.messaging.Producer, "_publish", old_publish) + + +@pytest.mark.forked +@mock.patch("celery.Celery.send_task") +def test_send_task_wrapped( + patched_send_task, + sentry_init, + capture_events, + reset_integrations, +): + sentry_init(integrations=[CeleryIntegration()], enable_tracing=True) + celery = Celery(__name__, broker="redis://example.com") # noqa: E231 + + events = capture_events() + + with sentry_sdk.start_transaction(name="custom_transaction"): + celery.send_task("very_creative_task_name", args=(1, 2), kwargs={"foo": "bar"}) + + (call,) = patched_send_task.call_args_list # We should have exactly one call + (args, kwargs) = call + + assert args == (celery, "very_creative_task_name") + assert kwargs["args"] == (1, 2) + assert kwargs["kwargs"] == {"foo": "bar"} + assert set(kwargs["headers"].keys()) == { + "sentry-task-enqueued-time", + "sentry-trace", + "baggage", + "headers", + } + assert set(kwargs["headers"]["headers"].keys()) == { + "sentry-trace", + "baggage", + "sentry-task-enqueued-time", + } + assert ( + kwargs["headers"]["sentry-trace"] + == kwargs["headers"]["headers"]["sentry-trace"] + ) + + (event,) = events # We should have exactly one event (the transaction) + assert event["type"] == "transaction" + assert event["transaction"] == "custom_transaction" + + (span,) = event["spans"] # We should have exactly one span + assert span["description"] == "very_creative_task_name" + assert span["op"] == "queue.submit.celery" + assert span["trace_id"] == kwargs["headers"]["sentry-trace"].split("-")[0] + + +@pytest.mark.skip(reason="placeholder so that forked test does not come last") +def test_placeholder(): + """Forked tests must not come last in the module. + See https://github.com/pytest-dev/pytest-forked/issues/67#issuecomment-1964718720. + """ + pass diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index abc27ccff4..063aed63ad 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -104,14 +104,16 @@ async def test_async_views(sentry_init, capture_events, application): @pytest.mark.skipif( django.VERSION < (3, 1), reason="async views have been introduced in Django 3.1" ) -async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, application): +async def test_active_thread_id( + sentry_init, capture_envelopes, teardown_profiling, endpoint, application +): with mock.patch( "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 ): sentry_init( integrations=[DjangoIntegration()], traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, + profiles_sample_rate=1.0, ) envelopes = capture_envelopes() @@ -121,17 +123,26 @@ async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, applic await comm.wait() assert response["status"] == 200, response["body"] - assert len(envelopes) == 1 - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 + assert len(envelopes) == 1 + + profiles = [item for item in envelopes[0].items if item.type == "profile"] + assert len(profiles) == 1 + + data = json.loads(response["body"]) + + for item in profiles: + transactions = item.payload.json["transactions"] + assert len(transactions) == 1 + assert str(data["active"]) == transactions[0]["active_thread_id"] - data = json.loads(response["body"]) + transactions = [item for item in envelopes[0].items if item.type == "transaction"] + assert len(transactions) == 1 - for profile in profiles: - transactions = profile.payload.json["transactions"] - assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + assert str(data["active"]) == trace_context["data"]["thread.id"] @pytest.mark.asyncio @@ -434,7 +445,7 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e [(b"content-type", b"application/json")], "post_echo_async", b'{"username":"xyz","password":"xyz"}', - {"username": "xyz", "password": "xyz"}, + {"username": "xyz", "password": "[Filtered]"}, ), ( True, @@ -453,7 +464,7 @@ async def test_trace_from_headers_if_performance_disabled(sentry_init, capture_e ], "post_echo_async", BODY_FORM, - {"password": "hello123", "photo": "", "username": "Jane"}, + {"password": "[Filtered]", "photo": "", "username": "Jane"}, ), ( False, @@ -624,3 +635,70 @@ async def test_async_view(sentry_init, capture_events, application): (event,) = events assert event["type"] == "transaction" assert event["transaction"] == "/simple_async_view" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +async def test_transaction_http_method_default( + sentry_init, capture_events, application +): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + comm = HttpCommunicator(application, "GET", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "OPTIONS", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "HEAD", "/simple_async_view") + await comm.get_response() + await comm.wait() + + (event,) = events + + assert len(events) == 1 + assert event["request"]["method"] == "GET" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +async def test_transaction_http_method_custom(sentry_init, capture_events, application): + sentry_init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ) + ], + traces_sample_rate=1.0, + ) + events = capture_events() + + comm = HttpCommunicator(application, "GET", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "OPTIONS", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "HEAD", "/simple_async_view") + await comm.get_response() + await comm.wait() + + assert len(events) == 2 + + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" diff --git a/tests/integrations/django/myapp/settings.py b/tests/integrations/django/myapp/settings.py index 0678762b6b..d70adf63ec 100644 --- a/tests/integrations/django/myapp/settings.py +++ b/tests/integrations/django/myapp/settings.py @@ -132,7 +132,7 @@ def middleware(request): except (ImportError, KeyError): from sentry_sdk.utils import logger - logger.warn("No psycopg2 found, testing with SQLite.") + logger.warning("No psycopg2 found, testing with SQLite.") # Password validation diff --git a/tests/integrations/django/myapp/urls.py b/tests/integrations/django/myapp/urls.py index b9e821afa8..79dd4edd52 100644 --- a/tests/integrations/django/myapp/urls.py +++ b/tests/integrations/django/myapp/urls.py @@ -43,6 +43,7 @@ def path(path, *args, **kwargs): ), path("middleware-exc", views.message, name="middleware_exc"), path("message", views.message, name="message"), + path("nomessage", views.nomessage, name="nomessage"), path("view-with-signal", views.view_with_signal, name="view_with_signal"), path("mylogin", views.mylogin, name="mylogin"), path("classbased", views.ClassBasedView.as_view(), name="classbased"), diff --git a/tests/integrations/django/myapp/views.py b/tests/integrations/django/myapp/views.py index c1950059fe..5e8cc39053 100644 --- a/tests/integrations/django/myapp/views.py +++ b/tests/integrations/django/myapp/views.py @@ -115,6 +115,11 @@ def message(request): return HttpResponse("ok") +@csrf_exempt +def nomessage(request): + return HttpResponse("ok") + + @csrf_exempt def view_with_signal(request): custom_signal = Signal() diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 45c25595f3..0e3f700105 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1,8 +1,11 @@ +import inspect import json import os import re +import sys import pytest from functools import partial +from unittest.mock import patch from werkzeug.test import Client @@ -10,6 +13,8 @@ from django.contrib.auth.models import User from django.core.management import execute_from_command_line from django.db.utils import OperationalError, ProgrammingError, DataError +from django.http.request import RawPostDataException +from django.utils.functional import SimpleLazyObject try: from django.urls import reverse @@ -20,9 +25,14 @@ from sentry_sdk._compat import PY310 from sentry_sdk import capture_message, capture_exception from sentry_sdk.consts import SPANDATA -from sentry_sdk.integrations.django import DjangoIntegration, _set_db_data +from sentry_sdk.integrations.django import ( + DjangoIntegration, + DjangoRequestExtractor, + _set_db_data, +) from sentry_sdk.integrations.django.signals_handlers import _get_receiver_name from sentry_sdk.integrations.executing import ExecutingIntegration +from sentry_sdk.profiler.utils import get_frame_name from sentry_sdk.tracing import Span from tests.conftest import unpack_werkzeug_response from tests.integrations.django.myapp.wsgi import application @@ -139,7 +149,11 @@ def test_transaction_with_class_view(sentry_init, client, capture_events): def test_has_trace_if_performance_enabled(sentry_init, client, capture_events): sentry_init( - integrations=[DjangoIntegration()], + integrations=[ + DjangoIntegration( + http_methods_to_capture=("HEAD",), + ) + ], traces_sample_rate=1.0, ) events = capture_events() @@ -186,7 +200,11 @@ def test_has_trace_if_performance_disabled(sentry_init, client, capture_events): def test_trace_from_headers_if_performance_enabled(sentry_init, client, capture_events): sentry_init( - integrations=[DjangoIntegration()], + integrations=[ + DjangoIntegration( + http_methods_to_capture=("HEAD",), + ) + ], traces_sample_rate=1.0, ) @@ -219,7 +237,11 @@ def test_trace_from_headers_if_performance_disabled( sentry_init, client, capture_events ): sentry_init( - integrations=[DjangoIntegration()], + integrations=[ + DjangoIntegration( + http_methods_to_capture=("HEAD",), + ) + ], ) events = capture_events() @@ -740,6 +762,26 @@ def test_read_request(sentry_init, client, capture_events): assert "data" not in event["request"] +def test_request_body_already_read(sentry_init, client, capture_events): + sentry_init(integrations=[DjangoIntegration()]) + + events = capture_events() + + class MockExtractor(DjangoRequestExtractor): + def raw_data(self): + raise RawPostDataException + + with patch("sentry_sdk.integrations.django.DjangoRequestExtractor", MockExtractor): + client.post( + reverse("post_echo"), data=b'{"hey": 42}', content_type="application/json" + ) + + (event,) = events + + assert event["message"] == "hi" + assert "data" not in event["request"] + + def test_template_tracing_meta(sentry_init, client, capture_events): sentry_init(integrations=[DjangoIntegration()]) events = capture_events() @@ -1157,3 +1199,147 @@ def test_span_origin(sentry_init, client, capture_events): signal_span_found = True assert signal_span_found + + +def test_transaction_http_method_default(sentry_init, client, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + (event,) = events + + assert len(events) == 1 + assert event["request"]["method"] == "GET" + + +def test_transaction_http_method_custom(sentry_init, client, capture_events): + sentry_init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ) + ], + traces_sample_rate=1.0, + ) + events = capture_events() + + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" + + +def test_ensures_spotlight_middleware_when_spotlight_is_enabled(sentry_init, settings): + """ + Test that ensures if Spotlight is enabled, relevant SpotlightMiddleware + is added to middleware list in settings. + """ + settings.DEBUG = True + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=True) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" in added + + +def test_ensures_no_spotlight_middleware_when_env_killswitch_is_false( + monkeypatch, sentry_init, settings +): + """ + Test that ensures if Spotlight is enabled, but is set to a falsy value + the relevant SpotlightMiddleware is NOT added to middleware list in settings. + """ + settings.DEBUG = True + monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "no") + + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=True) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added + + +def test_ensures_no_spotlight_middleware_when_no_spotlight( + monkeypatch, sentry_init, settings +): + """ + Test that ensures if Spotlight is not enabled + the relevant SpotlightMiddleware is NOT added to middleware list in settings. + """ + settings.DEBUG = True + + # We should NOT have the middleware even if the env var is truthy if Spotlight is off + monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "1") + + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=False) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added + + +def test_get_frame_name_when_in_lazy_object(): + allowed_to_init = False + + class SimpleLazyObjectWrapper(SimpleLazyObject): + def unproxied_method(self): + """ + For testing purposes. We inject a method on the SimpleLazyObject + class so if python is executing this method, we should get + this class instead of the wrapped class and avoid evaluating + the wrapped object too early. + """ + return inspect.currentframe() + + class GetFrame: + def __init__(self): + assert allowed_to_init, "GetFrame not permitted to initialize yet" + + def proxied_method(self): + """ + For testing purposes. We add an proxied method on the instance + class so if python is executing this method, we should get + this class instead of the wrapper class. + """ + return inspect.currentframe() + + instance = SimpleLazyObjectWrapper(lambda: GetFrame()) + + assert get_frame_name(instance.unproxied_method()) == ( + "SimpleLazyObjectWrapper.unproxied_method" + if sys.version_info < (3, 11) + else "test_get_frame_name_when_in_lazy_object..SimpleLazyObjectWrapper.unproxied_method" + ) + + # Now that we're about to access an instance method on the wrapped class, + # we should permit initializing it + allowed_to_init = True + + assert get_frame_name(instance.proxied_method()) == ( + "GetFrame.proxied_method" + if sys.version_info < (3, 11) + else "test_get_frame_name_when_in_lazy_object..GetFrame.proxied_method" + ) diff --git a/tests/integrations/django/test_middleware.py b/tests/integrations/django/test_middleware.py new file mode 100644 index 0000000000..2a8d94f623 --- /dev/null +++ b/tests/integrations/django/test_middleware.py @@ -0,0 +1,34 @@ +from typing import Optional + +import pytest + +from sentry_sdk.integrations.django.middleware import _wrap_middleware + + +def _sync_capable_middleware_factory(sync_capable): + # type: (Optional[bool]) -> type + """Create a middleware class with a sync_capable attribute set to the value passed to the factory. + If the factory is called with None, the middleware class will not have a sync_capable attribute. + """ + sc = sync_capable # rename so we can set sync_capable in the class + + class TestMiddleware: + nonlocal sc + if sc is not None: + sync_capable = sc + + return TestMiddleware + + +@pytest.mark.parametrize( + ("middleware", "sync_capable"), + ( + (_sync_capable_middleware_factory(True), True), + (_sync_capable_middleware_factory(False), False), + (_sync_capable_middleware_factory(None), True), + ), +) +def test_wrap_middleware_sync_capable_attribute(middleware, sync_capable): + wrapped_middleware = _wrap_middleware(middleware, "test_middleware") + + assert wrapped_middleware.sync_capable is sync_capable diff --git a/tests/integrations/dramatiq/__init__.py b/tests/integrations/dramatiq/__init__.py new file mode 100644 index 0000000000..70bbf21db4 --- /dev/null +++ b/tests/integrations/dramatiq/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("dramatiq") diff --git a/tests/integrations/dramatiq/test_dramatiq.py b/tests/integrations/dramatiq/test_dramatiq.py new file mode 100644 index 0000000000..d7917cbd00 --- /dev/null +++ b/tests/integrations/dramatiq/test_dramatiq.py @@ -0,0 +1,231 @@ +import pytest +import uuid + +import dramatiq +from dramatiq.brokers.stub import StubBroker + +import sentry_sdk +from sentry_sdk.integrations.dramatiq import DramatiqIntegration + + +@pytest.fixture +def broker(sentry_init): + sentry_init(integrations=[DramatiqIntegration()]) + broker = StubBroker() + broker.emit_after("process_boot") + dramatiq.set_broker(broker) + yield broker + broker.flush_all() + broker.close() + + +@pytest.fixture +def worker(broker): + worker = dramatiq.Worker(broker, worker_timeout=100, worker_threads=1) + worker.start() + yield worker + worker.stop() + + +def test_that_a_single_error_is_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send(1, 2) + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + exception = event["exception"]["values"][0] + assert exception["type"] == "ZeroDivisionError" + + +def test_that_actor_name_is_set_as_transaction(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + assert event["transaction"] == "dummy_actor" + + +def test_that_dramatiq_message_id_is_set_as_extra(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + sentry_sdk.capture_message("hi") + return x / y + + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + event_message, event_error = events + assert "dramatiq_message_id" in event_message["extra"] + assert "dramatiq_message_id" in event_error["extra"] + assert ( + event_message["extra"]["dramatiq_message_id"] + == event_error["extra"]["dramatiq_message_id"] + ) + msg_ids = [e["extra"]["dramatiq_message_id"] for e in events] + assert all(uuid.UUID(msg_id) and isinstance(msg_id, str) for msg_id in msg_ids) + + +def test_that_local_variables_are_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + foo = 42 # noqa + return x / y + + dummy_actor.send(1, 2) + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + exception = event["exception"]["values"][0] + assert exception["stacktrace"]["frames"][-1]["vars"] == { + "x": "1", + "y": "0", + "foo": "42", + } + + +def test_that_messages_are_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(): + sentry_sdk.capture_message("hi") + + dummy_actor.send() + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + assert event["message"] == "hi" + assert event["level"] == "info" + assert event["transaction"] == "dummy_actor" + + +def test_that_sub_actor_errors_are_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + sub_actor.send(x, y) + + @dramatiq.actor(max_retries=0) + def sub_actor(x, y): + return x / y + + dummy_actor.send(1, 2) + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + assert event["transaction"] == "sub_actor" + + exception = event["exception"]["values"][0] + assert exception["type"] == "ZeroDivisionError" + + +def test_that_multiple_errors_are_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send(1, 0) + broker.join(dummy_actor.queue_name) + worker.join() + + dummy_actor.send(1, None) + broker.join(dummy_actor.queue_name) + worker.join() + + event1, event2 = events + + assert event1["transaction"] == "dummy_actor" + exception = event1["exception"]["values"][0] + assert exception["type"] == "ZeroDivisionError" + + assert event2["transaction"] == "dummy_actor" + exception = event2["exception"]["values"][0] + assert exception["type"] == "TypeError" + + +def test_that_message_data_is_added_as_request(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=0) + def dummy_actor(x, y): + return x / y + + dummy_actor.send_with_options( + args=( + 1, + 0, + ), + max_retries=0, + ) + broker.join(dummy_actor.queue_name) + worker.join() + + (event,) = events + + assert event["transaction"] == "dummy_actor" + request_data = event["contexts"]["dramatiq"]["data"] + assert request_data["queue_name"] == "default" + assert request_data["actor_name"] == "dummy_actor" + assert request_data["args"] == [1, 0] + assert request_data["kwargs"] == {} + assert request_data["options"]["max_retries"] == 0 + assert uuid.UUID(request_data["message_id"]) + assert isinstance(request_data["message_timestamp"], int) + + +def test_that_expected_exceptions_are_not_captured(broker, worker, capture_events): + events = capture_events() + + class ExpectedException(Exception): + pass + + @dramatiq.actor(max_retries=0, throws=ExpectedException) + def dummy_actor(): + raise ExpectedException + + dummy_actor.send() + broker.join(dummy_actor.queue_name) + worker.join() + + assert events == [] + + +def test_that_retry_exceptions_are_not_captured(broker, worker, capture_events): + events = capture_events() + + @dramatiq.actor(max_retries=2) + def dummy_actor(): + raise dramatiq.errors.Retry("Retrying", delay=100) + + dummy_actor.send() + broker.join(dummy_actor.queue_name) + worker.join() + + assert events == [] diff --git a/tests/integrations/excepthook/test_excepthook.py b/tests/integrations/excepthook/test_excepthook.py index 7cb4e8b765..82fe6c6861 100644 --- a/tests/integrations/excepthook/test_excepthook.py +++ b/tests/integrations/excepthook/test_excepthook.py @@ -5,7 +5,14 @@ from textwrap import dedent -def test_excepthook(tmpdir): +TEST_PARAMETERS = [("", "HttpTransport")] + +if sys.version_info >= (3, 8): + TEST_PARAMETERS.append(('_experiments={"transport_http2": True}', "Http2Transport")) + + +@pytest.mark.parametrize("options, transport", TEST_PARAMETERS) +def test_excepthook(tmpdir, options, transport): app = tmpdir.join("app.py") app.write( dedent( @@ -18,14 +25,16 @@ def capture_envelope(self, envelope): if event is not None: print(event) - transport.HttpTransport.capture_envelope = capture_envelope + transport.{transport}.capture_envelope = capture_envelope - init("http://foobar@localhost/123") + init("http://foobar@localhost/123", {options}) frame_value = "LOL" 1/0 - """ + """.format( + transport=transport, options=options + ) ) ) @@ -40,7 +49,8 @@ def capture_envelope(self, envelope): assert b"capture_envelope was called" in output -def test_always_value_excepthook(tmpdir): +@pytest.mark.parametrize("options, transport", TEST_PARAMETERS) +def test_always_value_excepthook(tmpdir, options, transport): app = tmpdir.join("app.py") app.write( dedent( @@ -55,17 +65,20 @@ def capture_envelope(self, envelope): if event is not None: print(event) - transport.HttpTransport.capture_envelope = capture_envelope + transport.{transport}.capture_envelope = capture_envelope sys.ps1 = "always_value_test" init("http://foobar@localhost/123", - integrations=[ExcepthookIntegration(always_run=True)] + integrations=[ExcepthookIntegration(always_run=True)], + {options} ) frame_value = "LOL" 1/0 - """ + """.format( + transport=transport, options=options + ) ) ) diff --git a/tests/integrations/falcon/test_falcon.py b/tests/integrations/falcon/test_falcon.py index 0607d3fdeb..51a1d94334 100644 --- a/tests/integrations/falcon/test_falcon.py +++ b/tests/integrations/falcon/test_falcon.py @@ -460,3 +460,48 @@ def test_span_origin(sentry_init, capture_events, make_client): (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.falcon" + + +def test_falcon_request_media(sentry_init): + # test_passed stores whether the test has passed. + test_passed = False + + # test_failure_reason stores the reason why the test failed + # if test_passed is False. The value is meaningless when + # test_passed is True. + test_failure_reason = "test endpoint did not get called" + + class SentryCaptureMiddleware: + def process_request(self, _req, _resp): + # This capture message forces Falcon event processors to run + # before the request handler runs + sentry_sdk.capture_message("Processing request") + + class RequestMediaResource: + def on_post(self, req, _): + nonlocal test_passed, test_failure_reason + raw_data = req.bounded_stream.read() + + # If the raw_data is empty, the request body stream + # has been exhausted by the SDK. Test should fail in + # this case. + test_passed = raw_data != b"" + test_failure_reason = "request body has been read" + + sentry_init(integrations=[FalconIntegration()]) + + try: + app_class = falcon.App # Falcon ≥3.0 + except AttributeError: + app_class = falcon.API # Falcon <3.0 + + app = app_class(middleware=[SentryCaptureMiddleware()]) + app.add_route("/read_body", RequestMediaResource()) + + client = falcon.testing.TestClient(app) + + client.simulate_post("/read_body", json={"foo": "bar"}) + + # Check that simulate_post actually calls the resource, and + # that the SDK does not exhaust the request body stream. + assert test_passed, test_failure_reason diff --git a/tests/integrations/fastapi/test_fastapi.py b/tests/integrations/fastapi/test_fastapi.py index 7eaa0e0c90..97aea06344 100644 --- a/tests/integrations/fastapi/test_fastapi.py +++ b/tests/integrations/fastapi/test_fastapi.py @@ -1,9 +1,11 @@ import json import logging +import pytest import threading +import warnings from unittest import mock -import pytest +import fastapi from fastapi import FastAPI, HTTPException, Request from fastapi.testclient import TestClient from fastapi.middleware.trustedhost import TrustedHostMiddleware @@ -12,6 +14,12 @@ from sentry_sdk.integrations.asgi import SentryAsgiMiddleware from sentry_sdk.integrations.fastapi import FastApiIntegration from sentry_sdk.integrations.starlette import StarletteIntegration +from sentry_sdk.utils import parse_version + + +FASTAPI_VERSION = parse_version(fastapi.__version__) + +from tests.integrations.starlette import test_starlette def fastapi_app_factory(): @@ -28,6 +36,17 @@ async def _message(): capture_message("Hi") return {"message": "Hi"} + @app.delete("/nomessage") + @app.get("/nomessage") + @app.head("/nomessage") + @app.options("/nomessage") + @app.patch("/nomessage") + @app.post("/nomessage") + @app.put("/nomessage") + @app.trace("/nomessage") + async def _nomessage(): + return {"message": "nothing here..."} + @app.get("/message/{message_id}") async def _message_with_id(message_id): capture_message("Hi") @@ -165,7 +184,7 @@ def test_legacy_setup( def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): sentry_init( traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, + profiles_sample_rate=1.0, ) app = fastapi_app_factory() asgi_app = SentryAsgiMiddleware(app) @@ -184,11 +203,19 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en profiles = [item for item in envelopes[0].items if item.type == "profile"] assert len(profiles) == 1 - for profile in profiles: - transactions = profile.payload.json["transactions"] + for item in profiles: + transactions = item.payload.json["transactions"] assert len(transactions) == 1 assert str(data["active"]) == transactions[0]["active_thread_id"] + transactions = [item for item in envelopes[0].items if item.type == "transaction"] + assert len(transactions) == 1 + + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + assert str(data["active"]) == trace_context["data"]["thread.id"] + @pytest.mark.asyncio async def test_original_request_not_scrubbed(sentry_init, capture_events): @@ -503,38 +530,28 @@ def test_transaction_name_in_middleware( ) -@pytest.mark.parametrize( - "failed_request_status_codes,status_code,expected_error", - [ - (None, 500, True), - (None, 400, False), - ([500, 501], 500, True), - ([500, 501], 401, False), - ([range(400, 499)], 401, True), - ([range(400, 499)], 500, False), - ([range(400, 499), range(500, 599)], 300, False), - ([range(400, 499), range(500, 599)], 403, True), - ([range(400, 499), range(500, 599)], 503, True), - ([range(400, 403), 500, 501], 401, True), - ([range(400, 403), 500, 501], 405, False), - ([range(400, 403), 500, 501], 501, True), - ([range(400, 403), 500, 501], 503, False), - ([None], 500, False), - ], -) -def test_configurable_status_codes( +@test_starlette.parametrize_test_configurable_status_codes_deprecated +def test_configurable_status_codes_deprecated( sentry_init, capture_events, failed_request_status_codes, status_code, expected_error, ): + with pytest.warns(DeprecationWarning): + starlette_integration = StarletteIntegration( + failed_request_status_codes=failed_request_status_codes + ) + + with pytest.warns(DeprecationWarning): + fast_api_integration = FastApiIntegration( + failed_request_status_codes=failed_request_status_codes + ) + sentry_init( integrations=[ - StarletteIntegration( - failed_request_status_codes=failed_request_status_codes - ), - FastApiIntegration(failed_request_status_codes=failed_request_status_codes), + starlette_integration, + fast_api_integration, ] ) @@ -553,3 +570,114 @@ async def _error(): assert len(events) == 1 else: assert not events + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_default(sentry_init, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(), + FastApiIntegration(), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 1 + + (event,) = events + + assert event["request"]["method"] == "GET" + + +@pytest.mark.skipif( + FASTAPI_VERSION < (0, 80), + reason="Requires FastAPI >= 0.80, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_custom(sentry_init, capture_events): + # FastAPI is heavily based on Starlette so we also need + # to enable StarletteIntegration. + # In the future this will be auto enabled. + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + FastApiIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + ], + ) + + app = fastapi_app_factory() + + events = capture_events() + + client = TestClient(app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" + + +@test_starlette.parametrize_test_configurable_status_codes +def test_configurable_status_codes( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + integration_kwargs = {} + if failed_request_status_codes is not None: + integration_kwargs["failed_request_status_codes"] = failed_request_status_codes + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + starlette_integration = StarletteIntegration(**integration_kwargs) + fastapi_integration = FastApiIntegration(**integration_kwargs) + + sentry_init(integrations=[starlette_integration, fastapi_integration]) + + events = capture_events() + + app = FastAPI() + + @app.get("/error") + async def _error(): + raise HTTPException(status_code) + + client = TestClient(app) + client.get("/error") + + assert len(events) == int(expected_error) diff --git a/sentry_sdk/db/__init__.py b/tests/integrations/featureflags/__init__.py similarity index 100% rename from sentry_sdk/db/__init__.py rename to tests/integrations/featureflags/__init__.py diff --git a/tests/integrations/featureflags/test_featureflags.py b/tests/integrations/featureflags/test_featureflags.py new file mode 100644 index 0000000000..539e910607 --- /dev/null +++ b/tests/integrations/featureflags/test_featureflags.py @@ -0,0 +1,133 @@ +import concurrent.futures as cf +import sys + +import pytest + +import sentry_sdk +from sentry_sdk.integrations.featureflags import ( + FeatureFlagsIntegration, + add_feature_flag, +) + + +def test_featureflags_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + + add_feature_flag("hello", False) + add_feature_flag("world", True) + add_feature_flag("other", False) + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": True}, + {"flag": "other", "result": False}, + ] + } + + +def test_featureflags_integration_threaded( + sentry_init, capture_events, uninstall_integration +): + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + events = capture_events() + + # Capture an eval before we split isolation scopes. + add_feature_flag("hello", False) + + def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + add_feature_flag(flag_key, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + # Run tasks in separate threads + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_featureflags_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): + asyncio = pytest.importorskip("asyncio") + + uninstall_integration(FeatureFlagsIntegration.identifier) + sentry_init(integrations=[FeatureFlagsIntegration()]) + events = capture_events() + + # Capture an eval before we split isolation scopes. + add_feature_flag("hello", False) + + async def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + add_feature_flag(flag_key, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("world"), task("other")) + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": False}, + {"flag": "world", "result": False}, + ] + } diff --git a/tests/integrations/flask/test_flask.py b/tests/integrations/flask/test_flask.py index 03a3b0b9d0..6febb12b8b 100644 --- a/tests/integrations/flask/test_flask.py +++ b/tests/integrations/flask/test_flask.py @@ -47,6 +47,10 @@ def hi(): capture_message("hi") return "ok" + @app.route("/nomessage") + def nohi(): + return "ok" + @app.route("/message/") def hi_with_id(message_id): capture_message("hi again") @@ -962,3 +966,71 @@ def test_span_origin(sentry_init, app, capture_events): (_, event) = events assert event["contexts"]["trace"]["origin"] == "auto.http.flask" + + +def test_transaction_http_method_default( + sentry_init, + app, + capture_events, +): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[flask_sentry.FlaskIntegration()], + ) + events = capture_events() + + client = app.test_client() + response = client.get("/nomessage") + assert response.status_code == 200 + + response = client.options("/nomessage") + assert response.status_code == 200 + + response = client.head("/nomessage") + assert response.status_code == 200 + + (event,) = events + + assert len(events) == 1 + assert event["request"]["method"] == "GET" + + +def test_transaction_http_method_custom( + sentry_init, + app, + capture_events, +): + """ + Configure FlaskIntegration to ONLY capture OPTIONS and HEAD requests. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[ + flask_sentry.FlaskIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ) # capitalization does not matter + ) # case does not matter + ], + ) + events = capture_events() + + client = app.test_client() + response = client.get("/nomessage") + assert response.status_code == 200 + + response = client.options("/nomessage") + assert response.status_code == 200 + + response = client.head("/nomessage") + assert response.status_code == 200 + + assert len(events) == 2 + + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" diff --git a/tests/integrations/gcp/__init__.py b/tests/integrations/gcp/__init__.py new file mode 100644 index 0000000000..eaf1ba89bb --- /dev/null +++ b/tests/integrations/gcp/__init__.py @@ -0,0 +1,6 @@ +import pytest +import os + + +if "gcp" not in os.environ.get("TOX_ENV_NAME", ""): + pytest.skip("GCP tests only run in GCP environment", allow_module_level=True) diff --git a/tests/integrations/grpc/test_grpc.py b/tests/integrations/grpc/test_grpc.py index 66b65bbbf7..a8872ef0b5 100644 --- a/tests/integrations/grpc/test_grpc.py +++ b/tests/integrations/grpc/test_grpc.py @@ -357,7 +357,7 @@ class TestService(gRPCTestServiceServicer): def TestServe(request, context): # noqa: N802 with start_span( op="test", - description="test", + name="test", origin="auto.grpc.grpc.TestService", ): pass diff --git a/tests/integrations/grpc/test_grpc_aio.py b/tests/integrations/grpc/test_grpc_aio.py index 2ff91dcf16..9ce9aef6a5 100644 --- a/tests/integrations/grpc/test_grpc_aio.py +++ b/tests/integrations/grpc/test_grpc_aio.py @@ -21,22 +21,14 @@ AIO_PORT += os.getpid() % 100 # avoid port conflicts when running tests in parallel -@pytest.fixture(scope="function") -def event_loop(request): - """Create an instance of the default event loop for each test case.""" - loop = asyncio.new_event_loop() - yield loop - loop.close() - - @pytest_asyncio.fixture(scope="function") -async def grpc_server(sentry_init, event_loop): +async def grpc_server(sentry_init): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) server = grpc.aio.server() server.add_insecure_port("[::]:{}".format(AIO_PORT)) add_gRPCTestServiceServicer_to_server(TestService, server) - await event_loop.create_task(server.start()) + await asyncio.create_task(server.start()) try: yield server @@ -45,12 +37,12 @@ async def grpc_server(sentry_init, event_loop): @pytest.mark.asyncio -async def test_noop_for_unimplemented_method(event_loop, sentry_init, capture_events): +async def test_noop_for_unimplemented_method(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, integrations=[GRPCIntegration()]) server = grpc.aio.server() server.add_insecure_port("[::]:{}".format(AIO_PORT)) - await event_loop.create_task(server.start()) + await asyncio.create_task(server.start()) events = capture_events() try: @@ -282,7 +274,7 @@ def __init__(self): async def TestServe(cls, request, context): # noqa: N802 with start_span( op="test", - description="test", + name="test", origin="auto.grpc.grpc.TestService.aio", ): pass diff --git a/tests/integrations/launchdarkly/__init__.py b/tests/integrations/launchdarkly/__init__.py new file mode 100644 index 0000000000..06e09884c8 --- /dev/null +++ b/tests/integrations/launchdarkly/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("ldclient") diff --git a/tests/integrations/launchdarkly/test_launchdarkly.py b/tests/integrations/launchdarkly/test_launchdarkly.py new file mode 100644 index 0000000000..f66a4219ec --- /dev/null +++ b/tests/integrations/launchdarkly/test_launchdarkly.py @@ -0,0 +1,189 @@ +import concurrent.futures as cf +import sys + +import ldclient +import pytest + +from ldclient import LDClient +from ldclient.config import Config +from ldclient.context import Context +from ldclient.integrations.test_data import TestData + +import sentry_sdk +from sentry_sdk.integrations import DidNotEnable +from sentry_sdk.integrations.launchdarkly import LaunchDarklyIntegration + + +@pytest.mark.parametrize( + "use_global_client", + (False, True), +) +def test_launchdarkly_integration( + sentry_init, use_global_client, capture_events, uninstall_integration +): + td = TestData.data_source() + config = Config("sdk-key", update_processor_class=td) + + uninstall_integration(LaunchDarklyIntegration.identifier) + if use_global_client: + ldclient.set_config(config) + sentry_init(integrations=[LaunchDarklyIntegration()]) + client = ldclient.get() + else: + client = LDClient(config=config) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) + + # Set test values + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(True)) + + # Evaluate + client.variation("hello", Context.create("my-org", "organization"), False) + client.variation("world", Context.create("user1", "user"), False) + client.variation("other", Context.create("user2", "user"), False) + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": True}, + {"flag": "other", "result": False}, + ] + } + + +def test_launchdarkly_integration_threaded( + sentry_init, capture_events, uninstall_integration +): + td = TestData.data_source() + client = LDClient(config=Config("sdk-key", update_processor_class=td)) + context = Context.create("user1") + + uninstall_integration(LaunchDarklyIntegration.identifier) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) + events = capture_events() + + def task(flag_key): + # Creates a new isolation scope for the thread. + # This means the evaluations in each task are captured separately. + with sentry_sdk.isolation_scope(): + client.variation(flag_key, context, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(False)) + # Capture an eval before we split isolation scopes. + client.variation("hello", context, False) + + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_launchdarkly_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): + """Assert concurrently evaluated flags do not pollute one another.""" + + asyncio = pytest.importorskip("asyncio") + + td = TestData.data_source() + client = LDClient(config=Config("sdk-key", update_processor_class=td)) + context = Context.create("user1") + + uninstall_integration(LaunchDarklyIntegration.identifier) + sentry_init(integrations=[LaunchDarklyIntegration(ld_client=client)]) + events = capture_events() + + async def task(flag_key): + with sentry_sdk.isolation_scope(): + client.variation(flag_key, context, False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag_key) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("world"), task("other")) + + td.update(td.flag("hello").variation_for_all(True)) + td.update(td.flag("world").variation_for_all(False)) + # Capture an eval before we split isolation scopes. + client.variation("hello", context, False) + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +def test_launchdarkly_integration_did_not_enable(monkeypatch): + # Client is not passed in and set_config wasn't called. + # TODO: Bad practice to access internals like this. We can skip this test, or remove this + # case entirely (force user to pass in a client instance). + ldclient._reset_client() + try: + ldclient.__lock.lock() + ldclient.__config = None + finally: + ldclient.__lock.unlock() + + with pytest.raises(DidNotEnable): + LaunchDarklyIntegration() + + # Client not initialized. + client = LDClient(config=Config("sdk-key")) + monkeypatch.setattr(client, "is_initialized", lambda: False) + with pytest.raises(DidNotEnable): + LaunchDarklyIntegration(ld_client=client) diff --git a/tests/integrations/logging/test_logging.py b/tests/integrations/logging/test_logging.py index 02eb26a04d..8c325bc86c 100644 --- a/tests/integrations/logging/test_logging.py +++ b/tests/integrations/logging/test_logging.py @@ -77,11 +77,18 @@ def test_logging_extra_data_integer_keys(sentry_init, capture_events): assert event["extra"] == {"1": 1} -def test_logging_stack(sentry_init, capture_events): +@pytest.mark.parametrize( + "enable_stack_trace_kwarg", + ( + pytest.param({"exc_info": True}, id="exc_info"), + pytest.param({"stack_info": True}, id="stack_info"), + ), +) +def test_logging_stack_trace(sentry_init, capture_events, enable_stack_trace_kwarg): sentry_init(integrations=[LoggingIntegration()], default_integrations=False) events = capture_events() - logger.error("first", exc_info=True) + logger.error("first", **enable_stack_trace_kwarg) logger.error("second") ( diff --git a/tests/integrations/openai/test_openai.py b/tests/integrations/openai/test_openai.py index b0ffc9e768..011192e49f 100644 --- a/tests/integrations/openai/test_openai.py +++ b/tests/integrations/openai/test_openai.py @@ -1,5 +1,5 @@ import pytest -from openai import OpenAI, Stream, OpenAIError +from openai import AsyncOpenAI, OpenAI, AsyncStream, Stream, OpenAIError from openai.types import CompletionUsage, CreateEmbeddingResponse, Embedding from openai.types.chat import ChatCompletion, ChatCompletionMessage, ChatCompletionChunk from openai.types.chat.chat_completion import Choice @@ -7,10 +7,21 @@ from openai.types.create_embedding_response import Usage as EmbeddingTokenUsage from sentry_sdk import start_transaction -from sentry_sdk.integrations.openai import OpenAIIntegration +from sentry_sdk.integrations.openai import ( + OpenAIIntegration, + _calculate_chat_completion_usage, +) from unittest import mock # python 3.3 and above +try: + from unittest.mock import AsyncMock +except ImportError: + + class AsyncMock(mock.MagicMock): + async def __call__(self, *args, **kwargs): + return super(AsyncMock, self).__call__(*args, **kwargs) + EXAMPLE_CHAT_COMPLETION = ChatCompletion( id="chat-id", @@ -34,6 +45,11 @@ ) +async def async_iterator(values): + for value in values: + yield value + + @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], @@ -78,6 +94,48 @@ def test_nonstreaming_chat_completion( assert span["measurements"]["ai_total_tokens_used"]["value"] == 30 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_nonstreaming_chat_completion_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[OpenAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + client.chat.completions._post = AsyncMock(return_value=EXAMPLE_CHAT_COMPLETION) + + with start_transaction(name="openai tx"): + response = await client.chat.completions.create( + model="some-model", messages=[{"role": "system", "content": "hello"}] + ) + response = response.choices[0].message.content + + assert response == "the model response" + tx = events[0] + assert tx["type"] == "transaction" + span = tx["spans"][0] + assert span["op"] == "ai.chat_completions.create.openai" + + if send_default_pii and include_prompts: + assert "hello" in span["data"]["ai.input_messages"]["content"] + assert "the model response" in span["data"]["ai.responses"]["content"] + else: + assert "ai.input_messages" not in span["data"] + assert "ai.responses" not in span["data"] + + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 10 + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 20 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 30 + + def tiktoken_encoding_if_installed(): try: import tiktoken # type: ignore # noqa # pylint: disable=unused-import @@ -176,6 +234,102 @@ def test_streaming_chat_completion( pass # if tiktoken is not installed, we can't guarantee token usage will be calculated properly +# noinspection PyTypeChecker +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_streaming_chat_completion_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[ + OpenAIIntegration( + include_prompts=include_prompts, + tiktoken_encoding_name=tiktoken_encoding_if_installed(), + ) + ], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + returned_stream = AsyncStream(cast_to=None, response=None, client=client) + returned_stream._iterator = async_iterator( + [ + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=0, delta=ChoiceDelta(content="hel"), finish_reason=None + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=1, delta=ChoiceDelta(content="lo "), finish_reason=None + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=2, + delta=ChoiceDelta(content="world"), + finish_reason="stop", + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ] + ) + + client.chat.completions._post = AsyncMock(return_value=returned_stream) + with start_transaction(name="openai tx"): + response_stream = await client.chat.completions.create( + model="some-model", messages=[{"role": "system", "content": "hello"}] + ) + + response_string = "" + async for x in response_stream: + response_string += x.choices[0].delta.content + + assert response_string == "hello world" + tx = events[0] + assert tx["type"] == "transaction" + span = tx["spans"][0] + assert span["op"] == "ai.chat_completions.create.openai" + + if send_default_pii and include_prompts: + assert "hello" in span["data"]["ai.input_messages"]["content"] + assert "hello world" in span["data"]["ai.responses"] + else: + assert "ai.input_messages" not in span["data"] + assert "ai.responses" not in span["data"] + + try: + import tiktoken # type: ignore # noqa # pylint: disable=unused-import + + assert span["measurements"]["ai_completion_tokens_used"]["value"] == 2 + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 1 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 3 + except ImportError: + pass # if tiktoken is not installed, we can't guarantee token usage will be calculated properly + + def test_bad_chat_completion(sentry_init, capture_events): sentry_init(integrations=[OpenAIIntegration()], traces_sample_rate=1.0) events = capture_events() @@ -193,6 +347,24 @@ def test_bad_chat_completion(sentry_init, capture_events): assert event["level"] == "error" +@pytest.mark.asyncio +async def test_bad_chat_completion_async(sentry_init, capture_events): + sentry_init(integrations=[OpenAIIntegration()], traces_sample_rate=1.0) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + client.chat.completions._post = AsyncMock( + side_effect=OpenAIError("API rate limit reached") + ) + with pytest.raises(OpenAIError): + await client.chat.completions.create( + model="some-model", messages=[{"role": "system", "content": "hello"}] + ) + + (event,) = events + assert event["level"] == "error" + + @pytest.mark.parametrize( "send_default_pii, include_prompts", [(True, True), (True, False), (False, True), (False, False)], @@ -240,6 +412,109 @@ def test_embeddings_create( assert span["measurements"]["ai_total_tokens_used"]["value"] == 30 +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_embeddings_create_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[OpenAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + + returned_embedding = CreateEmbeddingResponse( + data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])], + model="some-model", + object="list", + usage=EmbeddingTokenUsage( + prompt_tokens=20, + total_tokens=30, + ), + ) + + client.embeddings._post = AsyncMock(return_value=returned_embedding) + with start_transaction(name="openai tx"): + response = await client.embeddings.create( + input="hello", model="text-embedding-3-large" + ) + + assert len(response.data[0].embedding) == 3 + + tx = events[0] + assert tx["type"] == "transaction" + span = tx["spans"][0] + assert span["op"] == "ai.embeddings.create.openai" + if send_default_pii and include_prompts: + assert "hello" in span["data"]["ai.input_messages"] + else: + assert "ai.input_messages" not in span["data"] + + assert span["measurements"]["ai_prompt_tokens_used"]["value"] == 20 + assert span["measurements"]["ai_total_tokens_used"]["value"] == 30 + + +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +def test_embeddings_create_raises_error( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[OpenAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + client = OpenAI(api_key="z") + + client.embeddings._post = mock.Mock( + side_effect=OpenAIError("API rate limit reached") + ) + + with pytest.raises(OpenAIError): + client.embeddings.create(input="hello", model="text-embedding-3-large") + + (event,) = events + assert event["level"] == "error" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [(True, True), (True, False), (False, True), (False, False)], +) +async def test_embeddings_create_raises_error_async( + sentry_init, capture_events, send_default_pii, include_prompts +): + sentry_init( + integrations=[OpenAIIntegration(include_prompts=include_prompts)], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + + client.embeddings._post = AsyncMock( + side_effect=OpenAIError("API rate limit reached") + ) + + with pytest.raises(OpenAIError): + await client.embeddings.create(input="hello", model="text-embedding-3-large") + + (event,) = events + assert event["level"] == "error" + + def test_span_origin_nonstreaming_chat(sentry_init, capture_events): sentry_init( integrations=[OpenAIIntegration()], @@ -261,6 +536,28 @@ def test_span_origin_nonstreaming_chat(sentry_init, capture_events): assert event["spans"][0]["origin"] == "auto.ai.openai" +@pytest.mark.asyncio +async def test_span_origin_nonstreaming_chat_async(sentry_init, capture_events): + sentry_init( + integrations=[OpenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + client.chat.completions._post = AsyncMock(return_value=EXAMPLE_CHAT_COMPLETION) + + with start_transaction(name="openai tx"): + await client.chat.completions.create( + model="some-model", messages=[{"role": "system", "content": "hello"}] + ) + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + assert event["spans"][0]["origin"] == "auto.ai.openai" + + def test_span_origin_streaming_chat(sentry_init, capture_events): sentry_init( integrations=[OpenAIIntegration()], @@ -311,6 +608,7 @@ def test_span_origin_streaming_chat(sentry_init, capture_events): response_stream = client.chat.completions.create( model="some-model", messages=[{"role": "system", "content": "hello"}] ) + "".join(map(lambda x: x.choices[0].delta.content, response_stream)) (event,) = events @@ -319,6 +617,72 @@ def test_span_origin_streaming_chat(sentry_init, capture_events): assert event["spans"][0]["origin"] == "auto.ai.openai" +@pytest.mark.asyncio +async def test_span_origin_streaming_chat_async(sentry_init, capture_events): + sentry_init( + integrations=[OpenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + returned_stream = AsyncStream(cast_to=None, response=None, client=client) + returned_stream._iterator = async_iterator( + [ + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=0, delta=ChoiceDelta(content="hel"), finish_reason=None + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=1, delta=ChoiceDelta(content="lo "), finish_reason=None + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ChatCompletionChunk( + id="1", + choices=[ + DeltaChoice( + index=2, + delta=ChoiceDelta(content="world"), + finish_reason="stop", + ) + ], + created=100000, + model="model-id", + object="chat.completion.chunk", + ), + ] + ) + + client.chat.completions._post = AsyncMock(return_value=returned_stream) + with start_transaction(name="openai tx"): + response_stream = await client.chat.completions.create( + model="some-model", messages=[{"role": "system", "content": "hello"}] + ) + async for _ in response_stream: + pass + + # "".join(map(lambda x: x.choices[0].delta.content, response_stream)) + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + assert event["spans"][0]["origin"] == "auto.ai.openai" + + def test_span_origin_embeddings(sentry_init, capture_events): sentry_init( integrations=[OpenAIIntegration()], @@ -346,3 +710,154 @@ def test_span_origin_embeddings(sentry_init, capture_events): assert event["contexts"]["trace"]["origin"] == "manual" assert event["spans"][0]["origin"] == "auto.ai.openai" + + +@pytest.mark.asyncio +async def test_span_origin_embeddings_async(sentry_init, capture_events): + sentry_init( + integrations=[OpenAIIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + client = AsyncOpenAI(api_key="z") + + returned_embedding = CreateEmbeddingResponse( + data=[Embedding(object="embedding", index=0, embedding=[1.0, 2.0, 3.0])], + model="some-model", + object="list", + usage=EmbeddingTokenUsage( + prompt_tokens=20, + total_tokens=30, + ), + ) + + client.embeddings._post = AsyncMock(return_value=returned_embedding) + with start_transaction(name="openai tx"): + await client.embeddings.create(input="hello", model="text-embedding-3-large") + + (event,) = events + + assert event["contexts"]["trace"]["origin"] == "manual" + assert event["spans"][0]["origin"] == "auto.ai.openai" + + +def test_calculate_chat_completion_usage_a(): + span = mock.MagicMock() + + def count_tokens(msg): + return len(str(msg)) + + response = mock.MagicMock() + response.usage = mock.MagicMock() + response.usage.completion_tokens = 10 + response.usage.prompt_tokens = 20 + response.usage.total_tokens = 30 + messages = [] + streaming_message_responses = [] + + with mock.patch( + "sentry_sdk.integrations.openai.record_token_usage" + ) as mock_record_token_usage: + _calculate_chat_completion_usage( + messages, response, span, streaming_message_responses, count_tokens + ) + mock_record_token_usage.assert_called_once_with(span, 20, 10, 30) + + +def test_calculate_chat_completion_usage_b(): + span = mock.MagicMock() + + def count_tokens(msg): + return len(str(msg)) + + response = mock.MagicMock() + response.usage = mock.MagicMock() + response.usage.completion_tokens = 10 + response.usage.total_tokens = 10 + messages = [ + {"content": "one"}, + {"content": "two"}, + {"content": "three"}, + ] + streaming_message_responses = [] + + with mock.patch( + "sentry_sdk.integrations.openai.record_token_usage" + ) as mock_record_token_usage: + _calculate_chat_completion_usage( + messages, response, span, streaming_message_responses, count_tokens + ) + mock_record_token_usage.assert_called_once_with(span, 11, 10, 10) + + +def test_calculate_chat_completion_usage_c(): + span = mock.MagicMock() + + def count_tokens(msg): + return len(str(msg)) + + response = mock.MagicMock() + response.usage = mock.MagicMock() + response.usage.prompt_tokens = 20 + response.usage.total_tokens = 20 + messages = [] + streaming_message_responses = [ + "one", + "two", + "three", + ] + + with mock.patch( + "sentry_sdk.integrations.openai.record_token_usage" + ) as mock_record_token_usage: + _calculate_chat_completion_usage( + messages, response, span, streaming_message_responses, count_tokens + ) + mock_record_token_usage.assert_called_once_with(span, 20, 11, 20) + + +def test_calculate_chat_completion_usage_d(): + span = mock.MagicMock() + + def count_tokens(msg): + return len(str(msg)) + + response = mock.MagicMock() + response.usage = mock.MagicMock() + response.usage.prompt_tokens = 20 + response.usage.total_tokens = 20 + response.choices = [ + mock.MagicMock(message="one"), + mock.MagicMock(message="two"), + mock.MagicMock(message="three"), + ] + messages = [] + streaming_message_responses = [] + + with mock.patch( + "sentry_sdk.integrations.openai.record_token_usage" + ) as mock_record_token_usage: + _calculate_chat_completion_usage( + messages, response, span, streaming_message_responses, count_tokens + ) + mock_record_token_usage.assert_called_once_with(span, 20, None, 20) + + +def test_calculate_chat_completion_usage_e(): + span = mock.MagicMock() + + def count_tokens(msg): + return len(str(msg)) + + response = mock.MagicMock() + messages = [] + streaming_message_responses = None + + with mock.patch( + "sentry_sdk.integrations.openai.record_token_usage" + ) as mock_record_token_usage: + _calculate_chat_completion_usage( + messages, response, span, streaming_message_responses, count_tokens + ) + mock_record_token_usage.assert_called_once_with(span, None, None, None) diff --git a/tests/integrations/openfeature/__init__.py b/tests/integrations/openfeature/__init__.py new file mode 100644 index 0000000000..a17549ea79 --- /dev/null +++ b/tests/integrations/openfeature/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("openfeature") diff --git a/tests/integrations/openfeature/test_openfeature.py b/tests/integrations/openfeature/test_openfeature.py new file mode 100644 index 0000000000..c180211c3f --- /dev/null +++ b/tests/integrations/openfeature/test_openfeature.py @@ -0,0 +1,153 @@ +import concurrent.futures as cf +import sys + +import pytest + +from openfeature import api +from openfeature.provider.in_memory_provider import InMemoryFlag, InMemoryProvider + +import sentry_sdk +from sentry_sdk.integrations.openfeature import OpenFeatureIntegration + + +def test_openfeature_integration(sentry_init, capture_events, uninstall_integration): + uninstall_integration(OpenFeatureIntegration.identifier) + sentry_init(integrations=[OpenFeatureIntegration()]) + + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + + client = api.get_client() + client.get_boolean_value("hello", default_value=False) + client.get_boolean_value("world", default_value=False) + client.get_boolean_value("other", default_value=True) + + events = capture_events() + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 1 + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + {"flag": "other", "result": True}, + ] + } + + +def test_openfeature_integration_threaded( + sentry_init, capture_events, uninstall_integration +): + uninstall_integration(OpenFeatureIntegration.identifier) + sentry_init(integrations=[OpenFeatureIntegration()]) + events = capture_events() + + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + + # Capture an eval before we split isolation scopes. + client = api.get_client() + client.get_boolean_value("hello", default_value=False) + + def task(flag): + # Create a new isolation scope for the thread. This means the flags + with sentry_sdk.isolation_scope(): + client.get_boolean_value(flag, default_value=False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag) + sentry_sdk.capture_exception(Exception("something wrong!")) + + # Run tasks in separate threads + with cf.ThreadPoolExecutor(max_workers=2) as pool: + pool.map(task, ["world", "other"]) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } + + +@pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") +def test_openfeature_integration_asyncio( + sentry_init, capture_events, uninstall_integration +): + """Assert concurrently evaluated flags do not pollute one another.""" + + asyncio = pytest.importorskip("asyncio") + + uninstall_integration(OpenFeatureIntegration.identifier) + sentry_init(integrations=[OpenFeatureIntegration()]) + events = capture_events() + + async def task(flag): + with sentry_sdk.isolation_scope(): + client.get_boolean_value(flag, default_value=False) + # use a tag to identify to identify events later on + sentry_sdk.set_tag("task_id", flag) + sentry_sdk.capture_exception(Exception("something wrong!")) + + async def runner(): + return asyncio.gather(task("world"), task("other")) + + flags = { + "hello": InMemoryFlag("on", {"on": True, "off": False}), + "world": InMemoryFlag("off", {"on": True, "off": False}), + } + api.set_provider(InMemoryProvider(flags)) + + # Capture an eval before we split isolation scopes. + client = api.get_client() + client.get_boolean_value("hello", default_value=False) + + asyncio.run(runner()) + + # Capture error in original scope + sentry_sdk.set_tag("task_id", "0") + sentry_sdk.capture_exception(Exception("something wrong!")) + + assert len(events) == 3 + events.sort(key=lambda e: e["tags"]["task_id"]) + + assert events[0]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + ] + } + assert events[1]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "other", "result": False}, + ] + } + assert events[2]["contexts"]["flags"] == { + "values": [ + {"flag": "hello", "result": True}, + {"flag": "world", "result": False}, + ] + } diff --git a/tests/integrations/opentelemetry/test_span_processor.py b/tests/integrations/opentelemetry/test_span_processor.py index 7045b52f17..ec5cf6af23 100644 --- a/tests/integrations/opentelemetry/test_span_processor.py +++ b/tests/integrations/opentelemetry/test_span_processor.py @@ -361,7 +361,7 @@ def test_on_start_child(): fake_span.start_child.assert_called_once_with( span_id="1234567890abcdef", - description="Sample OTel Span", + name="Sample OTel Span", start_timestamp=datetime.fromtimestamp( otel_span.start_time / 1e9, timezone.utc ), diff --git a/tests/integrations/quart/test_quart.py b/tests/integrations/quart/test_quart.py index 321f07e3c6..f15b968ac5 100644 --- a/tests/integrations/quart/test_quart.py +++ b/tests/integrations/quart/test_quart.py @@ -1,8 +1,8 @@ import json import threading +from unittest import mock import pytest -import pytest_asyncio import sentry_sdk from sentry_sdk import ( @@ -28,8 +28,7 @@ auth_manager = AuthManager() -@pytest_asyncio.fixture -async def app(): +def quart_app_factory(): app = Quart(__name__) app.debug = False app.config["TESTING"] = False @@ -73,8 +72,9 @@ def integration_enabled_params(request): @pytest.mark.asyncio -async def test_has_context(sentry_init, app, capture_events): +async def test_has_context(sentry_init, capture_events): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() client = app.test_client() @@ -99,7 +99,6 @@ async def test_has_context(sentry_init, app, capture_events): ) async def test_transaction_style( sentry_init, - app, capture_events, url, transaction_style, @@ -111,6 +110,7 @@ async def test_transaction_style( quart_sentry.QuartIntegration(transaction_style=transaction_style) ] ) + app = quart_app_factory() events = capture_events() client = app.test_client() @@ -126,10 +126,10 @@ async def test_errors( sentry_init, capture_exceptions, capture_events, - app, integration_enabled_params, ): sentry_init(**integration_enabled_params) + app = quart_app_factory() @app.route("/") async def index(): @@ -153,9 +153,10 @@ async def index(): @pytest.mark.asyncio async def test_quart_auth_not_installed( - sentry_init, app, capture_events, monkeypatch, integration_enabled_params + sentry_init, capture_events, monkeypatch, integration_enabled_params ): sentry_init(**integration_enabled_params) + app = quart_app_factory() monkeypatch.setattr(quart_sentry, "quart_auth", None) @@ -170,9 +171,10 @@ async def test_quart_auth_not_installed( @pytest.mark.asyncio async def test_quart_auth_not_configured( - sentry_init, app, capture_events, monkeypatch, integration_enabled_params + sentry_init, capture_events, monkeypatch, integration_enabled_params ): sentry_init(**integration_enabled_params) + app = quart_app_factory() assert quart_sentry.quart_auth @@ -186,9 +188,10 @@ async def test_quart_auth_not_configured( @pytest.mark.asyncio async def test_quart_auth_partially_configured( - sentry_init, app, capture_events, monkeypatch, integration_enabled_params + sentry_init, capture_events, monkeypatch, integration_enabled_params ): sentry_init(**integration_enabled_params) + app = quart_app_factory() events = capture_events() @@ -205,13 +208,13 @@ async def test_quart_auth_partially_configured( async def test_quart_auth_configured( send_default_pii, sentry_init, - app, user_id, capture_events, monkeypatch, integration_enabled_params, ): sentry_init(send_default_pii=send_default_pii, **integration_enabled_params) + app = quart_app_factory() @app.route("/login") async def login(): @@ -242,10 +245,9 @@ async def login(): [quart_sentry.QuartIntegration(), LoggingIntegration(event_level="ERROR")], ], ) -async def test_errors_not_reported_twice( - sentry_init, integrations, capture_events, app -): +async def test_errors_not_reported_twice(sentry_init, integrations, capture_events): sentry_init(integrations=integrations) + app = quart_app_factory() @app.route("/") async def index(): @@ -265,7 +267,7 @@ async def index(): @pytest.mark.asyncio -async def test_logging(sentry_init, capture_events, app): +async def test_logging(sentry_init, capture_events): # ensure that Quart's logger magic doesn't break ours sentry_init( integrations=[ @@ -273,6 +275,7 @@ async def test_logging(sentry_init, capture_events, app): LoggingIntegration(event_level="ERROR"), ] ) + app = quart_app_factory() @app.route("/") async def index(): @@ -289,13 +292,17 @@ async def index(): @pytest.mark.asyncio -async def test_no_errors_without_request(app, sentry_init): +async def test_no_errors_without_request(sentry_init): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() + async with app.app_context(): capture_exception(ValueError()) -def test_cli_commands_raise(app): +def test_cli_commands_raise(): + app = quart_app_factory() + if not hasattr(app, "cli"): pytest.skip("Too old quart version") @@ -312,8 +319,9 @@ def foo(): @pytest.mark.asyncio -async def test_500(sentry_init, app): +async def test_500(sentry_init): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() @app.route("/") async def index(): @@ -330,8 +338,9 @@ async def error_handler(err): @pytest.mark.asyncio -async def test_error_in_errorhandler(sentry_init, capture_events, app): +async def test_error_in_errorhandler(sentry_init, capture_events): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() @app.route("/") async def index(): @@ -358,8 +367,9 @@ async def error_handler(err): @pytest.mark.asyncio -async def test_bad_request_not_captured(sentry_init, capture_events, app): +async def test_bad_request_not_captured(sentry_init, capture_events): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() @app.route("/") @@ -374,8 +384,9 @@ async def index(): @pytest.mark.asyncio -async def test_does_not_leak_scope(sentry_init, capture_events, app): +async def test_does_not_leak_scope(sentry_init, capture_events): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() sentry_sdk.get_isolation_scope().set_tag("request_data", False) @@ -402,8 +413,9 @@ async def generate(): @pytest.mark.asyncio -async def test_scoped_test_client(sentry_init, app): +async def test_scoped_test_client(sentry_init): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() @app.route("/") async def index(): @@ -417,12 +429,13 @@ async def index(): @pytest.mark.asyncio @pytest.mark.parametrize("exc_cls", [ZeroDivisionError, Exception]) async def test_errorhandler_for_exception_swallows_exception( - sentry_init, app, capture_events, exc_cls + sentry_init, capture_events, exc_cls ): # In contrast to error handlers for a status code, error # handlers for exceptions can swallow the exception (this is # just how the Quart signal works) sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() @app.route("/") @@ -441,8 +454,9 @@ async def zerodivision(e): @pytest.mark.asyncio -async def test_tracing_success(sentry_init, capture_events, app): +async def test_tracing_success(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() @app.before_request async def _(): @@ -474,8 +488,9 @@ async def hi_tx(): @pytest.mark.asyncio -async def test_tracing_error(sentry_init, capture_events, app): +async def test_tracing_error(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0, integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() @@ -498,8 +513,9 @@ async def error(): @pytest.mark.asyncio -async def test_class_based_views(sentry_init, app, capture_events): +async def test_class_based_views(sentry_init, capture_events): sentry_init(integrations=[quart_sentry.QuartIntegration()]) + app = quart_app_factory() events = capture_events() @app.route("/") @@ -523,39 +539,56 @@ async def dispatch_request(self): @pytest.mark.parametrize("endpoint", ["/sync/thread_ids", "/async/thread_ids"]) -async def test_active_thread_id(sentry_init, capture_envelopes, endpoint, app): - sentry_init( - traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, - ) +@pytest.mark.asyncio +async def test_active_thread_id( + sentry_init, capture_envelopes, teardown_profiling, endpoint +): + with mock.patch( + "sentry_sdk.profiler.transaction_profiler.PROFILE_MINIMUM_SAMPLES", 0 + ): + sentry_init( + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + ) + app = quart_app_factory() - envelopes = capture_envelopes() + envelopes = capture_envelopes() - async with app.test_client() as client: - response = await client.get(endpoint) - assert response.status_code == 200 + async with app.test_client() as client: + response = await client.get(endpoint) + assert response.status_code == 200 + + data = json.loads(await response.get_data(as_text=True)) - data = json.loads(response.content) + envelopes = [envelope for envelope in envelopes] + assert len(envelopes) == 1 - envelopes = [envelope for envelope in envelopes] - assert len(envelopes) == 1 + profiles = [item for item in envelopes[0].items if item.type == "profile"] + assert len(profiles) == 1, envelopes[0].items - profiles = [item for item in envelopes[0].items if item.type == "profile"] - assert len(profiles) == 1 + for item in profiles: + transactions = item.payload.json["transactions"] + assert len(transactions) == 1 + assert str(data["active"]) == transactions[0]["active_thread_id"] - for profile in profiles: - transactions = profile.payload.json["transactions"] + transactions = [ + item for item in envelopes[0].items if item.type == "transaction" + ] assert len(transactions) == 1 - assert str(data["active"]) == transactions[0]["active_thread_id"] + + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + assert str(data["active"]) == trace_context["data"]["thread.id"] @pytest.mark.asyncio -async def test_span_origin(sentry_init, capture_events, app): +async def test_span_origin(sentry_init, capture_events): sentry_init( integrations=[quart_sentry.QuartIntegration()], traces_sample_rate=1.0, ) - + app = quart_app_factory() events = capture_events() client = app.test_client() diff --git a/tests/integrations/ray/__init__.py b/tests/integrations/ray/__init__.py new file mode 100644 index 0000000000..92f6d93906 --- /dev/null +++ b/tests/integrations/ray/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("ray") diff --git a/tests/integrations/ray/test_ray.py b/tests/integrations/ray/test_ray.py new file mode 100644 index 0000000000..95ab4ad0fa --- /dev/null +++ b/tests/integrations/ray/test_ray.py @@ -0,0 +1,222 @@ +import json +import os +import pytest + +import ray + +import sentry_sdk +from sentry_sdk.envelope import Envelope +from sentry_sdk.integrations.ray import RayIntegration +from tests.conftest import TestTransport + + +class RayTestTransport(TestTransport): + def __init__(self): + self.envelopes = [] + super().__init__() + + def capture_envelope(self, envelope: Envelope) -> None: + self.envelopes.append(envelope) + + +class RayLoggingTransport(TestTransport): + def __init__(self): + super().__init__() + + def capture_envelope(self, envelope: Envelope) -> None: + print(envelope.serialize().decode("utf-8", "replace")) + + +def setup_sentry_with_logging_transport(): + setup_sentry(transport=RayLoggingTransport()) + + +def setup_sentry(transport=None): + sentry_sdk.init( + integrations=[RayIntegration()], + transport=RayTestTransport() if transport is None else transport, + traces_sample_rate=1.0, + ) + + +def read_error_from_log(job_id): + log_dir = "/tmp/ray/session_latest/logs/" + log_file = [ + f + for f in os.listdir(log_dir) + if "worker" in f and job_id in f and f.endswith(".out") + ][0] + with open(os.path.join(log_dir, log_file), "r") as file: + lines = file.readlines() + + try: + # parse error object from log line + error = json.loads(lines[4][:-1]) + except IndexError: + error = None + + return error + + +@pytest.mark.forked +def test_tracing_in_ray_tasks(): + setup_sentry() + + ray.init( + runtime_env={ + "worker_process_setup_hook": setup_sentry, + "working_dir": "./", + } + ) + + # Setup ray task + @ray.remote + def example_task(): + with sentry_sdk.start_span(op="task", name="example task step"): + ... + + return sentry_sdk.get_client().transport.envelopes + + with sentry_sdk.start_transaction(op="task", name="ray test transaction"): + worker_envelopes = ray.get(example_task.remote()) + + client_envelope = sentry_sdk.get_client().transport.envelopes[0] + client_transaction = client_envelope.get_transaction_event() + assert client_transaction["transaction"] == "ray test transaction" + assert client_transaction["transaction_info"] == {"source": "custom"} + + worker_envelope = worker_envelopes[0] + worker_transaction = worker_envelope.get_transaction_event() + assert ( + worker_transaction["transaction"] + == "tests.integrations.ray.test_ray.test_tracing_in_ray_tasks..example_task" + ) + assert worker_transaction["transaction_info"] == {"source": "task"} + + (span,) = client_transaction["spans"] + assert span["op"] == "queue.submit.ray" + assert span["origin"] == "auto.queue.ray" + assert ( + span["description"] + == "tests.integrations.ray.test_ray.test_tracing_in_ray_tasks..example_task" + ) + assert span["parent_span_id"] == client_transaction["contexts"]["trace"]["span_id"] + assert span["trace_id"] == client_transaction["contexts"]["trace"]["trace_id"] + + (span,) = worker_transaction["spans"] + assert span["op"] == "task" + assert span["origin"] == "manual" + assert span["description"] == "example task step" + assert span["parent_span_id"] == worker_transaction["contexts"]["trace"]["span_id"] + assert span["trace_id"] == worker_transaction["contexts"]["trace"]["trace_id"] + + assert ( + client_transaction["contexts"]["trace"]["trace_id"] + == worker_transaction["contexts"]["trace"]["trace_id"] + ) + + +@pytest.mark.forked +def test_errors_in_ray_tasks(): + setup_sentry_with_logging_transport() + + ray.init( + runtime_env={ + "worker_process_setup_hook": setup_sentry_with_logging_transport, + "working_dir": "./", + } + ) + + # Setup ray task + @ray.remote + def example_task(): + 1 / 0 + + with sentry_sdk.start_transaction(op="task", name="ray test transaction"): + with pytest.raises(ZeroDivisionError): + future = example_task.remote() + ray.get(future) + + job_id = future.job_id().hex() + error = read_error_from_log(job_id) + + assert error["level"] == "error" + assert ( + error["transaction"] + == "tests.integrations.ray.test_ray.test_errors_in_ray_tasks..example_task" + ) + assert error["exception"]["values"][0]["mechanism"]["type"] == "ray" + assert not error["exception"]["values"][0]["mechanism"]["handled"] + + +@pytest.mark.forked +def test_tracing_in_ray_actors(): + setup_sentry() + + ray.init( + runtime_env={ + "worker_process_setup_hook": setup_sentry, + "working_dir": "./", + } + ) + + # Setup ray actor + @ray.remote + class Counter: + def __init__(self): + self.n = 0 + + def increment(self): + with sentry_sdk.start_span(op="task", name="example actor execution"): + self.n += 1 + + return sentry_sdk.get_client().transport.envelopes + + with sentry_sdk.start_transaction(op="task", name="ray test transaction"): + counter = Counter.remote() + worker_envelopes = ray.get(counter.increment.remote()) + + client_envelope = sentry_sdk.get_client().transport.envelopes[0] + client_transaction = client_envelope.get_transaction_event() + + # Spans for submitting the actor task are not created (actors are not supported yet) + assert client_transaction["spans"] == [] + + # Transaction are not yet created when executing ray actors (actors are not supported yet) + assert worker_envelopes == [] + + +@pytest.mark.forked +def test_errors_in_ray_actors(): + setup_sentry_with_logging_transport() + + ray.init( + runtime_env={ + "worker_process_setup_hook": setup_sentry_with_logging_transport, + "working_dir": "./", + } + ) + + # Setup ray actor + @ray.remote + class Counter: + def __init__(self): + self.n = 0 + + def increment(self): + with sentry_sdk.start_span(op="task", name="example actor execution"): + 1 / 0 + + return sentry_sdk.get_client().transport.envelopes + + with sentry_sdk.start_transaction(op="task", name="ray test transaction"): + with pytest.raises(ZeroDivisionError): + counter = Counter.remote() + future = counter.increment.remote() + ray.get(future) + + job_id = future.job_id().hex() + error = read_error_from_log(job_id) + + # We do not capture errors in ray actors yet + assert error is None diff --git a/tests/integrations/rust_tracing/__init__.py b/tests/integrations/rust_tracing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/rust_tracing/test_rust_tracing.py b/tests/integrations/rust_tracing/test_rust_tracing.py new file mode 100644 index 0000000000..893fc86966 --- /dev/null +++ b/tests/integrations/rust_tracing/test_rust_tracing.py @@ -0,0 +1,475 @@ +from unittest import mock +import pytest + +from string import Template +from typing import Dict + +import sentry_sdk +from sentry_sdk.integrations.rust_tracing import ( + RustTracingIntegration, + RustTracingLayer, + RustTracingLevel, + EventTypeMapping, +) +from sentry_sdk import start_transaction, capture_message + + +def _test_event_type_mapping(metadata: Dict[str, object]) -> EventTypeMapping: + level = RustTracingLevel(metadata.get("level")) + if level == RustTracingLevel.Error: + return EventTypeMapping.Exc + elif level in (RustTracingLevel.Warn, RustTracingLevel.Info): + return EventTypeMapping.Breadcrumb + elif level == RustTracingLevel.Debug: + return EventTypeMapping.Event + elif level == RustTracingLevel.Trace: + return EventTypeMapping.Ignore + else: + return EventTypeMapping.Ignore + + +class FakeRustTracing: + # Parameters: `level`, `index` + span_template = Template( + """{"index":$index,"is_root":false,"metadata":{"fields":["index","use_memoized","version"],"file":"src/lib.rs","is_event":false,"is_span":true,"level":"$level","line":40,"module_path":"_bindings","name":"fibonacci","target":"_bindings"},"parent":null,"use_memoized":true}""" + ) + + # Parameters: `level`, `index` + event_template = Template( + """{"message":"Getting the ${index}th fibonacci number","metadata":{"fields":["message"],"file":"src/lib.rs","is_event":true,"is_span":false,"level":"$level","line":23,"module_path":"_bindings","name":"event src/lib.rs:23","target":"_bindings"}}""" + ) + + def __init__(self): + self.spans = {} + + def set_layer_impl(self, layer: RustTracingLayer): + self.layer = layer + + def new_span(self, level: RustTracingLevel, span_id: int, index_arg: int = 10): + span_attrs = self.span_template.substitute(level=level.value, index=index_arg) + state = self.layer.on_new_span(span_attrs, str(span_id)) + self.spans[span_id] = state + + def close_span(self, span_id: int): + state = self.spans.pop(span_id) + self.layer.on_close(str(span_id), state) + + def event(self, level: RustTracingLevel, span_id: int, index_arg: int = 10): + event = self.event_template.substitute(level=level.value, index=index_arg) + state = self.spans[span_id] + self.layer.on_event(event, state) + + def record(self, span_id: int): + state = self.spans[span_id] + self.layer.on_record(str(span_id), """{"version": "memoized"}""", state) + + +def test_on_new_span_on_close(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_new_span_on_close", + initializer=rust_tracing.set_layer_impl, + include_tracing_fields=True, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + sentry_first_rust_span = sentry_sdk.get_current_span() + _, rust_first_rust_span = rust_tracing.spans[3] + + assert sentry_first_rust_span == rust_first_rust_span + + rust_tracing.close_span(3) + assert sentry_sdk.get_current_span() != sentry_first_rust_span + + (event,) = events + assert len(event["spans"]) == 1 + + # Ensure the span metadata is wired up + span = event["spans"][0] + assert span["op"] == "function" + assert span["origin"] == "auto.function.rust_tracing.test_on_new_span_on_close" + assert span["description"] == "_bindings::fibonacci" + + # Ensure the span was opened/closed appropriately + assert span["start_timestamp"] is not None + assert span["timestamp"] is not None + + # Ensure the extra data from Rust is hooked up + data = span["data"] + assert data["use_memoized"] + assert data["index"] == 10 + assert data["version"] is None + + +def test_nested_on_new_span_on_close(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_nested_on_new_span_on_close", + initializer=rust_tracing.set_layer_impl, + include_tracing_fields=True, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + with start_transaction(): + original_sentry_span = sentry_sdk.get_current_span() + + rust_tracing.new_span(RustTracingLevel.Info, 3, index_arg=10) + sentry_first_rust_span = sentry_sdk.get_current_span() + _, rust_first_rust_span = rust_tracing.spans[3] + + # Use a different `index_arg` value for the inner span to help + # distinguish the two at the end of the test + rust_tracing.new_span(RustTracingLevel.Info, 5, index_arg=9) + sentry_second_rust_span = sentry_sdk.get_current_span() + rust_parent_span, rust_second_rust_span = rust_tracing.spans[5] + + assert rust_second_rust_span == sentry_second_rust_span + assert rust_parent_span == sentry_first_rust_span + assert rust_parent_span == rust_first_rust_span + assert rust_parent_span != rust_second_rust_span + + rust_tracing.close_span(5) + + # Ensure the current sentry span was moved back to the parent + sentry_span_after_close = sentry_sdk.get_current_span() + assert sentry_span_after_close == sentry_first_rust_span + + rust_tracing.close_span(3) + + assert sentry_sdk.get_current_span() == original_sentry_span + + (event,) = events + assert len(event["spans"]) == 2 + + # Ensure the span metadata is wired up for all spans + first_span, second_span = event["spans"] + assert first_span["op"] == "function" + assert ( + first_span["origin"] + == "auto.function.rust_tracing.test_nested_on_new_span_on_close" + ) + assert first_span["description"] == "_bindings::fibonacci" + assert second_span["op"] == "function" + assert ( + second_span["origin"] + == "auto.function.rust_tracing.test_nested_on_new_span_on_close" + ) + assert second_span["description"] == "_bindings::fibonacci" + + # Ensure the spans were opened/closed appropriately + assert first_span["start_timestamp"] is not None + assert first_span["timestamp"] is not None + assert second_span["start_timestamp"] is not None + assert second_span["timestamp"] is not None + + # Ensure the extra data from Rust is hooked up in both spans + first_span_data = first_span["data"] + assert first_span_data["use_memoized"] + assert first_span_data["index"] == 10 + assert first_span_data["version"] is None + + second_span_data = second_span["data"] + assert second_span_data["use_memoized"] + assert second_span_data["index"] == 9 + assert second_span_data["version"] is None + + +def test_on_new_span_without_transaction(sentry_init): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_new_span_without_transaction", rust_tracing.set_layer_impl + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + assert sentry_sdk.get_current_span() is None + + # Should still create a span hierarchy, it just will not be under a txn + rust_tracing.new_span(RustTracingLevel.Info, 3) + current_span = sentry_sdk.get_current_span() + assert current_span is not None + assert current_span.containing_transaction is None + + +def test_on_event_exception(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_event_exception", + rust_tracing.set_layer_impl, + event_type_mapping=_test_event_type_mapping, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + sentry_sdk.get_isolation_scope().clear_breadcrumbs() + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + # Mapped to Exception + rust_tracing.event(RustTracingLevel.Error, 3) + + rust_tracing.close_span(3) + + assert len(events) == 2 + exc, _tx = events + assert exc["level"] == "error" + assert exc["logger"] == "_bindings" + assert exc["message"] == "Getting the 10th fibonacci number" + assert exc["breadcrumbs"]["values"] == [] + + location_context = exc["contexts"]["rust_tracing_location"] + assert location_context["module_path"] == "_bindings" + assert location_context["file"] == "src/lib.rs" + assert location_context["line"] == 23 + + field_context = exc["contexts"]["rust_tracing_fields"] + assert field_context["message"] == "Getting the 10th fibonacci number" + + +def test_on_event_breadcrumb(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_event_breadcrumb", + rust_tracing.set_layer_impl, + event_type_mapping=_test_event_type_mapping, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + sentry_sdk.get_isolation_scope().clear_breadcrumbs() + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + # Mapped to Breadcrumb + rust_tracing.event(RustTracingLevel.Info, 3) + + rust_tracing.close_span(3) + capture_message("test message") + + assert len(events) == 2 + message, _tx = events + + breadcrumbs = message["breadcrumbs"]["values"] + assert len(breadcrumbs) == 1 + assert breadcrumbs[0]["level"] == "info" + assert breadcrumbs[0]["message"] == "Getting the 10th fibonacci number" + assert breadcrumbs[0]["type"] == "default" + + +def test_on_event_event(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_event_event", + rust_tracing.set_layer_impl, + event_type_mapping=_test_event_type_mapping, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + sentry_sdk.get_isolation_scope().clear_breadcrumbs() + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + # Mapped to Event + rust_tracing.event(RustTracingLevel.Debug, 3) + + rust_tracing.close_span(3) + + assert len(events) == 2 + event, _tx = events + + assert event["logger"] == "_bindings" + assert event["level"] == "debug" + assert event["message"] == "Getting the 10th fibonacci number" + assert event["breadcrumbs"]["values"] == [] + + location_context = event["contexts"]["rust_tracing_location"] + assert location_context["module_path"] == "_bindings" + assert location_context["file"] == "src/lib.rs" + assert location_context["line"] == 23 + + field_context = event["contexts"]["rust_tracing_fields"] + assert field_context["message"] == "Getting the 10th fibonacci number" + + +def test_on_event_ignored(sentry_init, capture_events): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_on_event_ignored", + rust_tracing.set_layer_impl, + event_type_mapping=_test_event_type_mapping, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + sentry_sdk.get_isolation_scope().clear_breadcrumbs() + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + # Ignored + rust_tracing.event(RustTracingLevel.Trace, 3) + + rust_tracing.close_span(3) + + assert len(events) == 1 + (tx,) = events + assert tx["type"] == "transaction" + assert "message" not in tx + + +def test_span_filter(sentry_init, capture_events): + def span_filter(metadata: Dict[str, object]) -> bool: + return RustTracingLevel(metadata.get("level")) in ( + RustTracingLevel.Error, + RustTracingLevel.Warn, + RustTracingLevel.Info, + RustTracingLevel.Debug, + ) + + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_span_filter", + initializer=rust_tracing.set_layer_impl, + span_filter=span_filter, + include_tracing_fields=True, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + events = capture_events() + with start_transaction(): + original_sentry_span = sentry_sdk.get_current_span() + + # Span is not ignored + rust_tracing.new_span(RustTracingLevel.Info, 3, index_arg=10) + info_span = sentry_sdk.get_current_span() + + # Span is ignored, current span should remain the same + rust_tracing.new_span(RustTracingLevel.Trace, 5, index_arg=9) + assert sentry_sdk.get_current_span() == info_span + + # Closing the filtered span should leave the current span alone + rust_tracing.close_span(5) + assert sentry_sdk.get_current_span() == info_span + + rust_tracing.close_span(3) + assert sentry_sdk.get_current_span() == original_sentry_span + + (event,) = events + assert len(event["spans"]) == 1 + # The ignored span has index == 9 + assert event["spans"][0]["data"]["index"] == 10 + + +def test_record(sentry_init): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_record", + initializer=rust_tracing.set_layer_impl, + include_tracing_fields=True, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + span_before_record = sentry_sdk.get_current_span().to_json() + assert span_before_record["data"]["version"] is None + + rust_tracing.record(3) + + span_after_record = sentry_sdk.get_current_span().to_json() + assert span_after_record["data"]["version"] == "memoized" + + +def test_record_in_ignored_span(sentry_init): + def span_filter(metadata: Dict[str, object]) -> bool: + # Just ignore Trace + return RustTracingLevel(metadata.get("level")) != RustTracingLevel.Trace + + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_record_in_ignored_span", + rust_tracing.set_layer_impl, + span_filter=span_filter, + include_tracing_fields=True, + ) + sentry_init(integrations=[integration], traces_sample_rate=1.0) + + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + span_before_record = sentry_sdk.get_current_span().to_json() + assert span_before_record["data"]["version"] is None + + rust_tracing.new_span(RustTracingLevel.Trace, 5) + rust_tracing.record(5) + + # `on_record()` should not do anything to the current Sentry span if the associated Rust span was ignored + span_after_record = sentry_sdk.get_current_span().to_json() + assert span_after_record["data"]["version"] is None + + +@pytest.mark.parametrize( + "send_default_pii, include_tracing_fields, tracing_fields_expected", + [ + (True, True, True), + (True, False, False), + (True, None, True), + (False, True, True), + (False, False, False), + (False, None, False), + ], +) +def test_include_tracing_fields( + sentry_init, send_default_pii, include_tracing_fields, tracing_fields_expected +): + rust_tracing = FakeRustTracing() + integration = RustTracingIntegration( + "test_record", + initializer=rust_tracing.set_layer_impl, + include_tracing_fields=include_tracing_fields, + ) + + sentry_init( + integrations=[integration], + traces_sample_rate=1.0, + send_default_pii=send_default_pii, + ) + with start_transaction(): + rust_tracing.new_span(RustTracingLevel.Info, 3) + + span_before_record = sentry_sdk.get_current_span().to_json() + if tracing_fields_expected: + assert span_before_record["data"]["version"] is None + else: + assert span_before_record["data"]["version"] == "[Filtered]" + + rust_tracing.record(3) + + span_after_record = sentry_sdk.get_current_span().to_json() + + if tracing_fields_expected: + assert span_after_record["data"] == { + "thread.id": mock.ANY, + "thread.name": mock.ANY, + "use_memoized": True, + "version": "memoized", + "index": 10, + } + + else: + assert span_after_record["data"] == { + "thread.id": mock.ANY, + "thread.name": mock.ANY, + "use_memoized": "[Filtered]", + "version": "[Filtered]", + "index": "[Filtered]", + } diff --git a/tests/integrations/sanic/test_sanic.py b/tests/integrations/sanic/test_sanic.py index 598bae0134..9d95907144 100644 --- a/tests/integrations/sanic/test_sanic.py +++ b/tests/integrations/sanic/test_sanic.py @@ -26,7 +26,7 @@ except ImportError: ReusableClient = None -from sentry_sdk._types import TYPE_CHECKING +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Iterable, Container diff --git a/tests/integrations/spark/test_spark.py b/tests/integrations/spark/test_spark.py index 58c8862ee2..44ba9f8728 100644 --- a/tests/integrations/spark/test_spark.py +++ b/tests/integrations/spark/test_spark.py @@ -1,6 +1,7 @@ import pytest import sys from unittest.mock import patch + from sentry_sdk.integrations.spark.spark_driver import ( _set_app_properties, _start_sentry_listener, @@ -18,8 +19,22 @@ ################ -def test_set_app_properties(): - spark_context = SparkContext(appName="Testing123") +@pytest.fixture(scope="function") +def sentry_init_with_reset(sentry_init): + from sentry_sdk.integrations import _processed_integrations + + yield lambda: sentry_init(integrations=[SparkIntegration()]) + _processed_integrations.remove("spark") + + +@pytest.fixture(scope="function") +def create_spark_context(): + yield lambda: SparkContext(appName="Testing123") + SparkContext._active_spark_context.stop() + + +def test_set_app_properties(create_spark_context): + spark_context = create_spark_context() _set_app_properties() assert spark_context.getLocalProperty("sentry_app_name") == "Testing123" @@ -30,9 +45,8 @@ def test_set_app_properties(): ) -def test_start_sentry_listener(): - spark_context = SparkContext.getOrCreate() - +def test_start_sentry_listener(create_spark_context): + spark_context = create_spark_context() gateway = spark_context._gateway assert gateway._callback_server is None @@ -41,9 +55,28 @@ def test_start_sentry_listener(): assert gateway._callback_server is not None -def test_initialize_spark_integration(sentry_init): - sentry_init(integrations=[SparkIntegration()]) - SparkContext.getOrCreate() +@patch("sentry_sdk.integrations.spark.spark_driver._patch_spark_context_init") +def test_initialize_spark_integration_before_spark_context_init( + mock_patch_spark_context_init, + sentry_init_with_reset, + create_spark_context, +): + sentry_init_with_reset() + create_spark_context() + + mock_patch_spark_context_init.assert_called_once() + + +@patch("sentry_sdk.integrations.spark.spark_driver._activate_integration") +def test_initialize_spark_integration_after_spark_context_init( + mock_activate_integration, + create_spark_context, + sentry_init_with_reset, +): + create_spark_context() + sentry_init_with_reset() + + mock_activate_integration.assert_called_once() @pytest.fixture @@ -54,88 +87,83 @@ def sentry_listener(): return listener -@pytest.fixture -def mock_add_breadcrumb(): - with patch("sentry_sdk.add_breadcrumb") as mock: - yield mock - - -def test_sentry_listener_on_job_start(sentry_listener, mock_add_breadcrumb): +def test_sentry_listener_on_job_start(sentry_listener): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class MockJobStart: - def jobId(self): # noqa: N802 - return "sample-job-id-start" + class MockJobStart: + def jobId(self): # noqa: N802 + return "sample-job-id-start" - mock_job_start = MockJobStart() - listener.onJobStart(mock_job_start) + mock_job_start = MockJobStart() + listener.onJobStart(mock_job_start) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-job-id-start" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["level"] == "info" + assert "sample-job-id-start" in mock_hub.kwargs["message"] @pytest.mark.parametrize( "job_result, level", [("JobSucceeded", "info"), ("JobFailed", "warning")] ) -def test_sentry_listener_on_job_end( - sentry_listener, mock_add_breadcrumb, job_result, level -): +def test_sentry_listener_on_job_end(sentry_listener, job_result, level): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class MockJobResult: - def toString(self): # noqa: N802 - return job_result + class MockJobResult: + def toString(self): # noqa: N802 + return job_result - class MockJobEnd: - def jobId(self): # noqa: N802 - return "sample-job-id-end" + class MockJobEnd: + def jobId(self): # noqa: N802 + return "sample-job-id-end" - def jobResult(self): # noqa: N802 - result = MockJobResult() - return result + def jobResult(self): # noqa: N802 + result = MockJobResult() + return result - mock_job_end = MockJobEnd() - listener.onJobEnd(mock_job_end) + mock_job_end = MockJobEnd() + listener.onJobEnd(mock_job_end) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == level - assert mock_hub.kwargs["data"]["result"] == job_result - assert "sample-job-id-end" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["level"] == level + assert mock_hub.kwargs["data"]["result"] == job_result + assert "sample-job-id-end" in mock_hub.kwargs["message"] -def test_sentry_listener_on_stage_submitted(sentry_listener, mock_add_breadcrumb): +def test_sentry_listener_on_stage_submitted(sentry_listener): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: - class StageInfo: - def stageId(self): # noqa: N802 - return "sample-stage-id-submit" + class StageInfo: + def stageId(self): # noqa: N802 + return "sample-stage-id-submit" - def name(self): - return "run-job" + def name(self): + return "run-job" - def attemptId(self): # noqa: N802 - return 14 + def attemptId(self): # noqa: N802 + return 14 - class MockStageSubmitted: - def stageInfo(self): # noqa: N802 - stageinf = StageInfo() - return stageinf + class MockStageSubmitted: + def stageInfo(self): # noqa: N802 + stageinf = StageInfo() + return stageinf - mock_stage_submitted = MockStageSubmitted() - listener.onStageSubmitted(mock_stage_submitted) + mock_stage_submitted = MockStageSubmitted() + listener.onStageSubmitted(mock_stage_submitted) - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" + assert mock_hub.kwargs["level"] == "info" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" @pytest.fixture @@ -175,39 +203,39 @@ def stageInfo(self): # noqa: N802 def test_sentry_listener_on_stage_completed_success( - sentry_listener, mock_add_breadcrumb, get_mock_stage_completed + sentry_listener, get_mock_stage_completed ): listener = sentry_listener + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: + mock_stage_completed = get_mock_stage_completed(failure_reason=False) + listener.onStageCompleted(mock_stage_completed) - mock_stage_completed = get_mock_stage_completed(failure_reason=False) - listener.onStageCompleted(mock_stage_completed) - - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args - assert mock_hub.kwargs["level"] == "info" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" - assert "reason" not in mock_hub.kwargs["data"] + assert mock_hub.kwargs["level"] == "info" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" + assert "reason" not in mock_hub.kwargs["data"] def test_sentry_listener_on_stage_completed_failure( - sentry_listener, mock_add_breadcrumb, get_mock_stage_completed + sentry_listener, get_mock_stage_completed ): listener = sentry_listener - - mock_stage_completed = get_mock_stage_completed(failure_reason=True) - listener.onStageCompleted(mock_stage_completed) - - mock_add_breadcrumb.assert_called_once() - mock_hub = mock_add_breadcrumb.call_args - - assert mock_hub.kwargs["level"] == "warning" - assert "sample-stage-id-submit" in mock_hub.kwargs["message"] - assert mock_hub.kwargs["data"]["attemptId"] == 14 - assert mock_hub.kwargs["data"]["name"] == "run-job" - assert mock_hub.kwargs["data"]["reason"] == "failure-reason" + with patch.object(listener, "_add_breadcrumb") as mock_add_breadcrumb: + mock_stage_completed = get_mock_stage_completed(failure_reason=True) + listener.onStageCompleted(mock_stage_completed) + + mock_add_breadcrumb.assert_called_once() + mock_hub = mock_add_breadcrumb.call_args + + assert mock_hub.kwargs["level"] == "warning" + assert "sample-stage-id-submit" in mock_hub.kwargs["message"] + assert mock_hub.kwargs["data"]["attemptId"] == 14 + assert mock_hub.kwargs["data"]["name"] == "run-job" + assert mock_hub.kwargs["data"]["reason"] == "failure-reason" ################ diff --git a/tests/integrations/starlette/test_starlette.py b/tests/integrations/starlette/test_starlette.py index 918ad1185e..fd47895f5a 100644 --- a/tests/integrations/starlette/test_starlette.py +++ b/tests/integrations/starlette/test_starlette.py @@ -6,6 +6,7 @@ import os import re import threading +import warnings from unittest import mock import pytest @@ -112,6 +113,9 @@ async def _message(request): capture_message("hi") return starlette.responses.JSONResponse({"status": "ok"}) + async def _nomessage(request): + return starlette.responses.JSONResponse({"status": "ok"}) + async def _message_with_id(request): capture_message("hi") return starlette.responses.JSONResponse({"status": "ok"}) @@ -141,12 +145,25 @@ async def _render_template(request): } return templates.TemplateResponse("trace_meta.html", template_context) + all_methods = [ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", + ] + app = starlette.applications.Starlette( debug=debug, routes=[ starlette.routing.Route("/some_url", _homepage), starlette.routing.Route("/custom_error", _custom_error), starlette.routing.Route("/message", _message), + starlette.routing.Route("/nomessage", _nomessage, methods=all_methods), starlette.routing.Route("/message/{message_id}", _message_with_id), starlette.routing.Route("/sync/thread_ids", _thread_ids_sync), starlette.routing.Route("/async/thread_ids", _thread_ids_async), @@ -868,7 +885,7 @@ def test_legacy_setup( def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, endpoint): sentry_init( traces_sample_rate=1.0, - _experiments={"profiles_sample_rate": 1.0}, + profiles_sample_rate=1.0, ) app = starlette_app_factory() asgi_app = SentryAsgiMiddleware(app) @@ -887,11 +904,19 @@ def test_active_thread_id(sentry_init, capture_envelopes, teardown_profiling, en profiles = [item for item in envelopes[0].items if item.type == "profile"] assert len(profiles) == 1 - for profile in profiles: - transactions = profile.payload.json["transactions"] + for item in profiles: + transactions = item.payload.json["transactions"] assert len(transactions) == 1 assert str(data["active"]) == transactions[0]["active_thread_id"] + transactions = [item for item in envelopes[0].items if item.type == "transaction"] + assert len(transactions) == 1 + + for item in transactions: + transaction = item.payload.json + trace_context = transaction["contexts"]["trace"] + assert str(data["active"]) == trace_context["data"]["thread.id"] + def test_original_request_not_scrubbed(sentry_init, capture_events): sentry_init(integrations=[StarletteIntegration()]) @@ -1133,7 +1158,22 @@ def test_span_origin(sentry_init, capture_events): assert span["origin"] == "auto.http.starlette" -@pytest.mark.parametrize( +class NonIterableContainer: + """Wraps any container and makes it non-iterable. + + Used to test backwards compatibility with our old way of defining failed_request_status_codes, which allowed + passing in a list of (possibly non-iterable) containers. The Python standard library does not provide any built-in + non-iterable containers, so we have to define our own. + """ + + def __init__(self, inner): + self.inner = inner + + def __contains__(self, item): + return item in self.inner + + +parametrize_test_configurable_status_codes_deprecated = pytest.mark.parametrize( "failed_request_status_codes,status_code,expected_error", [ (None, 500, True), @@ -1149,23 +1189,30 @@ def test_span_origin(sentry_init, capture_events): ([range(400, 403), 500, 501], 405, False), ([range(400, 403), 500, 501], 501, True), ([range(400, 403), 500, 501], 503, False), - ([None], 500, False), + ([], 500, False), + ([NonIterableContainer(range(500, 600))], 500, True), + ([NonIterableContainer(range(500, 600))], 404, False), ], ) -def test_configurable_status_codes( +"""Test cases for configurable status codes (deprecated API). +Also used by the FastAPI tests. +""" + + +@parametrize_test_configurable_status_codes_deprecated +def test_configurable_status_codes_deprecated( sentry_init, capture_events, failed_request_status_codes, status_code, expected_error, ): - sentry_init( - integrations=[ - StarletteIntegration( - failed_request_status_codes=failed_request_status_codes - ) - ] - ) + with pytest.warns(DeprecationWarning): + starlette_integration = StarletteIntegration( + failed_request_status_codes=failed_request_status_codes + ) + + sentry_init(integrations=[starlette_integration]) events = capture_events() @@ -1185,3 +1232,123 @@ async def _error(request): assert len(events) == 1 else: assert not events + + +@pytest.mark.skipif( + STARLETTE_VERSION < (0, 21), + reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_default(sentry_init, capture_events): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration(), + ], + ) + events = capture_events() + + starlette_app = starlette_app_factory() + + client = TestClient(starlette_app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 1 + + (event,) = events + + assert event["request"]["method"] == "GET" + + +@pytest.mark.skipif( + STARLETTE_VERSION < (0, 21), + reason="Requires Starlette >= 0.21, because earlier versions do not support HTTP 'HEAD' requests", +) +def test_transaction_http_method_custom(sentry_init, capture_events): + sentry_init( + traces_sample_rate=1.0, + integrations=[ + StarletteIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ), + ], + debug=True, + ) + events = capture_events() + + starlette_app = starlette_app_factory() + + client = TestClient(starlette_app) + client.get("/nomessage") + client.options("/nomessage") + client.head("/nomessage") + + assert len(events) == 2 + + (event1, event2) = events + + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" + + +parametrize_test_configurable_status_codes = pytest.mark.parametrize( + ("failed_request_status_codes", "status_code", "expected_error"), + ( + (None, 500, True), + (None, 400, False), + ({500, 501}, 500, True), + ({500, 501}, 401, False), + ({*range(400, 500)}, 401, True), + ({*range(400, 500)}, 500, False), + ({*range(400, 600)}, 300, False), + ({*range(400, 600)}, 403, True), + ({*range(400, 600)}, 503, True), + ({*range(400, 403), 500, 501}, 401, True), + ({*range(400, 403), 500, 501}, 405, False), + ({*range(400, 403), 500, 501}, 501, True), + ({*range(400, 403), 500, 501}, 503, False), + (set(), 500, False), + ), +) + + +@parametrize_test_configurable_status_codes +def test_configurable_status_codes( + sentry_init, + capture_events, + failed_request_status_codes, + status_code, + expected_error, +): + integration_kwargs = {} + if failed_request_status_codes is not None: + integration_kwargs["failed_request_status_codes"] = failed_request_status_codes + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + starlette_integration = StarletteIntegration(**integration_kwargs) + + sentry_init(integrations=[starlette_integration]) + + events = capture_events() + + async def _error(_): + raise HTTPException(status_code) + + app = starlette.applications.Starlette( + routes=[ + starlette.routing.Route("/error", _error, methods=["GET"]), + ], + ) + + client = TestClient(app) + client.get("/error") + + assert len(events) == int(expected_error) diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index c327331608..200b282f53 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,5 +1,6 @@ import random from http.client import HTTPConnection, HTTPSConnection +from socket import SocketIO from urllib.request import urlopen from unittest import mock @@ -342,3 +343,35 @@ def test_span_origin(sentry_init, capture_events): assert event["spans"][0]["op"] == "http.client" assert event["spans"][0]["origin"] == "auto.http.stdlib.httplib" + + +def test_http_timeout(monkeypatch, sentry_init, capture_envelopes): + mock_readinto = mock.Mock(side_effect=TimeoutError) + monkeypatch.setattr(SocketIO, "readinto", mock_readinto) + + sentry_init(traces_sample_rate=1.0) + + envelopes = capture_envelopes() + + with start_transaction(op="op", name="name"): + try: + conn = HTTPSConnection("www.squirrelchasers.com") + conn.request("GET", "/top-chasers") + conn.getresponse() + except Exception: + pass + + items = [ + item + for envelope in envelopes + for item in envelope.items + if item.type == "transaction" + ] + assert len(items) == 1 + + transaction = items[0].payload.json + assert len(transaction["spans"]) == 1 + + span = transaction["spans"][0] + assert span["op"] == "http.client" + assert span["description"] == "GET https://www.squirrelchasers.com/top-chasers" diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index dcc6632bdb..7b40b238d2 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -10,10 +10,6 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from flask import Flask -from strawberry.extensions.tracing import ( - SentryTracingExtension, - SentryTracingExtensionSync, -) from strawberry.fastapi import GraphQLRouter from strawberry.flask.views import GraphQLView @@ -28,6 +24,15 @@ ) from tests.conftest import ApproxDict +try: + from strawberry.extensions.tracing import ( + SentryTracingExtension, + SentryTracingExtensionSync, + ) +except ImportError: + SentryTracingExtension = None + SentryTracingExtensionSync = None + parameterize_strawberry_test = pytest.mark.parametrize( "client_factory,async_execution,framework_integrations", ( @@ -143,6 +148,10 @@ def test_infer_execution_type_from_installed_packages_sync(sentry_init): assert SentrySyncExtension in schema.extensions +@pytest.mark.skipif( + SentryTracingExtension is None, + reason="SentryTracingExtension no longer available in this Strawberry version", +) def test_replace_existing_sentry_async_extension(sentry_init): sentry_init(integrations=[StrawberryIntegration()]) @@ -152,6 +161,10 @@ def test_replace_existing_sentry_async_extension(sentry_init): assert SentryAsyncExtension in schema.extensions +@pytest.mark.skipif( + SentryTracingExtensionSync is None, + reason="SentryTracingExtensionSync no longer available in this Strawberry version", +) def test_replace_existing_sentry_sync_extension(sentry_init): sentry_init(integrations=[StrawberryIntegration()]) diff --git a/tests/integrations/sys_exit/test_sys_exit.py b/tests/integrations/sys_exit/test_sys_exit.py new file mode 100644 index 0000000000..81a950c7c0 --- /dev/null +++ b/tests/integrations/sys_exit/test_sys_exit.py @@ -0,0 +1,71 @@ +import sys + +import pytest + +from sentry_sdk.integrations.sys_exit import SysExitIntegration + + +@pytest.mark.parametrize( + ("integration_params", "exit_status", "should_capture"), + ( + ({}, 0, False), + ({}, 1, True), + ({}, None, False), + ({}, "unsuccessful exit", True), + ({"capture_successful_exits": False}, 0, False), + ({"capture_successful_exits": False}, 1, True), + ({"capture_successful_exits": False}, None, False), + ({"capture_successful_exits": False}, "unsuccessful exit", True), + ({"capture_successful_exits": True}, 0, True), + ({"capture_successful_exits": True}, 1, True), + ({"capture_successful_exits": True}, None, True), + ({"capture_successful_exits": True}, "unsuccessful exit", True), + ), +) +def test_sys_exit( + sentry_init, capture_events, integration_params, exit_status, should_capture +): + sentry_init(integrations=[SysExitIntegration(**integration_params)]) + + events = capture_events() + + # Manually catch the sys.exit rather than using pytest.raises because IDE does not recognize that pytest.raises + # will catch SystemExit. + try: + sys.exit(exit_status) + except SystemExit: + ... + else: + pytest.fail("Patched sys.exit did not raise SystemExit") + + if should_capture: + (event,) = events + (exception_value,) = event["exception"]["values"] + + assert exception_value["type"] == "SystemExit" + assert exception_value["value"] == ( + str(exit_status) if exit_status is not None else "" + ) + else: + assert len(events) == 0 + + +def test_sys_exit_integration_not_auto_enabled(sentry_init, capture_events): + sentry_init() # No SysExitIntegration + + events = capture_events() + + # Manually catch the sys.exit rather than using pytest.raises because IDE does not recognize that pytest.raises + # will catch SystemExit. + try: + sys.exit(1) + except SystemExit: + ... + else: + pytest.fail( + "sys.exit should not be patched, but it must have been because it did not raise SystemExit" + ) + + assert ( + len(events) == 0 + ), "No events should have been captured because sys.exit should not have been patched" diff --git a/tests/integrations/threading/test_threading.py b/tests/integrations/threading/test_threading.py index 2b6b280c1e..0d14fae352 100644 --- a/tests/integrations/threading/test_threading.py +++ b/tests/integrations/threading/test_threading.py @@ -80,7 +80,7 @@ def test_propagates_threadpool_hub(sentry_init, capture_events, propagate_hub): events = capture_events() def double(number): - with sentry_sdk.start_span(op="task", description=str(number)): + with sentry_sdk.start_span(op="task", name=str(number)): return number * 2 with sentry_sdk.start_transaction(name="test_handles_threadpool"): diff --git a/tests/integrations/typer/__init__.py b/tests/integrations/typer/__init__.py new file mode 100644 index 0000000000..3b7c8011ea --- /dev/null +++ b/tests/integrations/typer/__init__.py @@ -0,0 +1,3 @@ +import pytest + +pytest.importorskip("typer") diff --git a/tests/integrations/typer/test_typer.py b/tests/integrations/typer/test_typer.py new file mode 100644 index 0000000000..34ac0a7c8c --- /dev/null +++ b/tests/integrations/typer/test_typer.py @@ -0,0 +1,52 @@ +import subprocess +import sys +from textwrap import dedent +import pytest + +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_catch_exceptions(tmpdir): + app = tmpdir.join("app.py") + + app.write( + dedent( + """ + import typer + from unittest import mock + + from sentry_sdk import init, transport + from sentry_sdk.integrations.typer import TyperIntegration + + def capture_envelope(self, envelope): + print("capture_envelope was called") + event = envelope.get_event() + if event is not None: + print(event) + + transport.HttpTransport.capture_envelope = capture_envelope + + init("http://foobar@localhost/123", integrations=[TyperIntegration()]) + + app = typer.Typer() + + @app.command() + def test(): + print("test called") + raise Exception("pollo") + + app() + """ + ) + ) + + with pytest.raises(subprocess.CalledProcessError) as excinfo: + subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT) + + output = excinfo.value.output + + assert b"capture_envelope was called" in output + assert b"test called" in output + assert b"pollo" in output diff --git a/tests/profiler/test_continuous_profiler.py b/tests/profiler/test_continuous_profiler.py index de647a6a45..1b96f27036 100644 --- a/tests/profiler/test_continuous_profiler.py +++ b/tests/profiler/test_continuous_profiler.py @@ -168,6 +168,7 @@ def assert_single_transaction_without_profile_chunks(envelopes): assert "profile" not in transaction["contexts"] +@pytest.mark.forked @pytest.mark.parametrize( "mode", [ diff --git a/tests/test.key b/tests/test.key new file mode 100644 index 0000000000..bf066c169d --- /dev/null +++ b/tests/test.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQCNSgCTO5Pc7o21 +BfvfDv/UDwDydEhInosNG7lgumqelT4dyJcYWoiDYAZ8zf6mlPFaw3oYouq+nQo/ +Z5eRNQD6AxhXw86qANjcfs1HWoP8d7jgR+ZelrshadvBBGYUJhiDkjUWb8jU7b9M +28z5m4SA5enfSrQYZfVlrX8MFxV70ws5duLye92FYjpqFBWeeGtmsw1iWUO020Nj +bbngpcRmRiBq41KuPydD8IWWQteoOVAI3U2jwEI2foAkXTHB+kQF//NtUWz5yiZY +4ugjY20p0t8Asom1oDK9pL2Qy4EQpsCev/6SJ+o7sK6oR1gyrzodn6hcqJbqcXvp +Y6xgXIO02H8wn7e3NkAJZkfFWJAyIslYrurMcnZwDaLpzL35vyULseOtDfsWQ3yq +TflXHcA2Zlujuv7rmq6Q+GCaLJxbmj5bPUvv8DAARd97BXf57s6C9srT8kk5Ekbf +URWRiO8j5XDLPyqsaP1c/pMPee1CGdtY6gf9EDWgmivgAYvH27pqzKh0JJAsmJ8p +1Zp5xFMtEkzoTlKL2jqeyS6zBO/o+9MHJld5OHcUvlWm767vKKe++aV2IA3h9nBQ +vmbCQ9i0ufGXZYZtJUYk6T8EMLclvtQz4yLRAYx0PLFOKfi1pAfDAHBFEfwWmuCk +cYqw8erbbfoj0qpnuDEj45iUtH5gRwIDAQABAoICADqdqfFrNSPiYC3qxpy6x039 +z4HG1joydDPC/bxwek1CU1vd3TmATcRbMTXT7ELF5f+mu1+/Ly5XTmoRmyLl33rZ +j97RYErNQSrw/E8O8VTrgmqhyaQSWp45Ia9JGORhDaiAHsApLiOQYt4LDlW7vFQR +jl5RyreYjR9axCuK5CHT44M6nFrHIpb0spFRtcph4QThYbscl2dP0/xLCGN3wixA +CbDukF2z26FnBrTZFEk5Rcf3r/8wgwfCoXz0oPD91/y5PA9tSY2z3QbhVDdiR2aj +klritxj/1i0xTGfm1avH0n/J3V5bauTKnxs3RhL4+V5S33FZjArFfAfOjzQHDah6 +nqz43dAOf83QYreMivxyAnQvU3Cs+J4RKYUsIQzsLpRs/2Wb7nK3W/p+bLdRIl04 +Y+xcX+3aKBluKoVMh7CeQDtr8NslSNO+YfGNmGYfD2f05da1Wi+FWqTrXXY2Y/NB +3VJDLgMuNgT5nsimrCl6ZfNcBtyDhsCUPN9V8sGZooEnjG0eNIX/OO3mlEI5GXfY +oFoXsjPX53aYZkOPVZLdXq0IteKGCFZCBhDVOmAqgALlVl66WbO+pMlBB+L7aw/h +H1NlBmrzfOXlYZi8SbmO0DSqC0ckXZCSdbmjix9aOhpDk/NlUZF29xCfQ5Mwk4gk +FboJIKDa0kKXQB18UV4ZAoIBAQC/LX97kOa1YibZIYdkyo0BD8jgjXZGV3y0Lc5V +h5mjOUD2mQ2AE9zcKtfjxEBnFYcC5RFe88vWBuYyLpVdDuZeiAfQHP4bXT+QZRBi +p51PjMuC+5zd5XlGeU5iwnfJ6TBe0yVfSb7M2N88LEeBaVCRcP7rqyiSYnwVkaHN +9Ow1PwJ4BiX0wIn62fO6o6CDo8x9KxXK6G+ak5z83AFSV8+ZGjHMEYcLaVfOj8a2 +VFbc2eX1V0ebgJOZVx8eAgjLV6fJahJ1/lT+8y9CzHtS7b3RvU/EsD+7WLMFUxHJ +cPVL6/iHBsV8heKxFfdORSBtBgllQjzv6rzuJ2rZDqQBZF0TAoIBAQC9MhjeEtNw +J8jrnsfg5fDJMPCg5nvb6Ck3z2FyDPJInK+b/IPvcrDl/+X+1vHhmGf5ReLZuEPR +0YEeAWbdMiKJbgRyca5xWRWgP7+sIFmJ9Calvf0FfFzaKQHyLAepBuVp5JMCqqTc +9Rw+5X5MjRgQxvJRppO/EnrvJ3/ZPJEhvYaSqvFQpYR4U0ghoQSlSxoYwCNuKSga +EmpItqZ1j6bKCxy/TZbYgM2SDoSzsD6h/hlLLIU6ecIsBPrF7C+rwxasbLLomoCD +RqjCjsLsgiQU9Qmg01ReRWjXa64r0JKGU0gb+E365WJHqPQgyyhmeYhcXhhUCj+B +Anze8CYU8xp9AoIBAFOpjYh9uPjXoziSO7YYDezRA4+BWKkf0CrpgMpdNRcBDzTb +ddT+3EBdX20FjUmPWi4iIJ/1ANcA3exIBoVa5+WmkgS5K1q+S/rcv3bs8yLE8qq3 +gcZ5jcERhQQjJljt+4UD0e8JTr5GiirDFefENsXvNR/dHzwwbSzjNnPzIwuKL4Jm +7mVVfQySJN8gjDYPkIWWPUs2vOBgiOr/PHTUiLzvgatUYEzWJN74fHV+IyUzFjdv +op6iffU08yEmssKJ8ZtrF/ka/Ac2VRBee/mmoNMQjb/9gWZzQqSp3bbSAAbhlTlB +9VqxHKtyeW9/QNl1MtdlTVWQ3G08Qr4KcitJyJECggEAL3lrrgXxUnpZO26bXz6z +vfhu2SEcwWCvPxblr9W50iinFDA39xTDeONOljTfeylgJbe4pcNMGVFF4f6eDjEv +Y2bc7M7D5CNjftOgSBPSBADk1cAnxoGfVwrlNxx/S5W0aW72yLuDJQLIdKvnllPt +TwBs+7od5ts/R9WUijFdhabmJtWIOiFebUcQmYeq/8MpqD5GZbUkH+6xBs/2UxeZ +1acWLpbMnEUt0FGeUOyPutxlAm0IfVTiOWOCfbm3eJU6kkewWRez2b0YScHC/c/m +N/AI23dL+1/VYADgMpRiwBwTwxj6kFOQ5sRphfUUjSo/4lWmKyhrKPcz2ElQdP9P +jQKCAQEAqsAD7r443DklL7oPR/QV0lrjv11EtXcZ0Gff7ZF2FI1V/CxkbYolPrB+ +QPSjwcMtyzxy6tXtUnaH19gx/K/8dBO/vnBw1Go/tvloIXidvVE0wemEC+gpTVtP +fLVplwBhcyxOMMGJcqbIT62pzSUisyXeb8dGn27BOUqz69u+z+MKdHDMM/loKJbj +TRw8MB8+t51osJ/tA3SwQCzS4onUMmwqE9eVHspANQeWZVqs+qMtpwW0lvs909Wv +VZ1o9pRPv2G9m7aK4v/bZO56DOx+9/Rp+mv3S2zl2Pkd6RIuD0UR4v03bRz3ACpf +zQTVuucYfxc1ph7H0ppUOZQNZ1Fo7w== +-----END PRIVATE KEY----- diff --git a/tests/test.pem b/tests/test.pem new file mode 100644 index 0000000000..2473a09452 --- /dev/null +++ b/tests/test.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFETCCAvkCFEtmfMHeEvO+RUV9Qx0bkr7VWpdSMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjQwOTE3MjEwNDE1WhcNMjUwOTE3MjEw +NDE1WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOC +Ag8AMIICCgKCAgEAjUoAkzuT3O6NtQX73w7/1A8A8nRISJ6LDRu5YLpqnpU+HciX +GFqIg2AGfM3+ppTxWsN6GKLqvp0KP2eXkTUA+gMYV8POqgDY3H7NR1qD/He44Efm +Xpa7IWnbwQRmFCYYg5I1Fm/I1O2/TNvM+ZuEgOXp30q0GGX1Za1/DBcVe9MLOXbi +8nvdhWI6ahQVnnhrZrMNYllDtNtDY2254KXEZkYgauNSrj8nQ/CFlkLXqDlQCN1N +o8BCNn6AJF0xwfpEBf/zbVFs+comWOLoI2NtKdLfALKJtaAyvaS9kMuBEKbAnr/+ +kifqO7CuqEdYMq86HZ+oXKiW6nF76WOsYFyDtNh/MJ+3tzZACWZHxViQMiLJWK7q +zHJ2cA2i6cy9+b8lC7HjrQ37FkN8qk35Vx3ANmZbo7r+65qukPhgmiycW5o+Wz1L +7/AwAEXfewV3+e7OgvbK0/JJORJG31EVkYjvI+Vwyz8qrGj9XP6TD3ntQhnbWOoH +/RA1oJor4AGLx9u6asyodCSQLJifKdWaecRTLRJM6E5Si9o6nskuswTv6PvTByZX +eTh3FL5Vpu+u7yinvvmldiAN4fZwUL5mwkPYtLnxl2WGbSVGJOk/BDC3Jb7UM+Mi +0QGMdDyxTin4taQHwwBwRRH8FprgpHGKsPHq2236I9KqZ7gxI+OYlLR+YEcCAwEA +ATANBgkqhkiG9w0BAQsFAAOCAgEAgFVmFmk7duJRYqktcc4/qpbGUQTaalcjBvMQ +SnTS0l3WNTwOeUBbCR6V72LOBhRG1hqsQJIlXFIuoFY7WbQoeHciN58abwXan3N+ +4Kzuue5oFdj2AK9UTSKE09cKHoBD5uwiuU1oMGRxvq0+nUaJMoC333TNBXlIFV6K +SZFfD+MpzoNdn02PtjSBzsu09szzC+r8ZyKUwtG6xTLRBA8vrukWgBYgn9CkniJk +gLw8z5FioOt8ISEkAqvtyfJPi0FkUBb/vFXwXaaM8Vvn++ssYiUes0K5IzF+fQ5l +Bv8PIkVXFrNKuvzUgpO9IaUuQavSHFC0w0FEmbWsku7UxgPvLFPqmirwcnrkQjVR +eyE25X2Sk6AucnfIFGUvYPcLGJ71Z8mjH0baB2a/zo8vnWR1rqiUfptNomm42WMm +PaprIC0684E0feT+cqbN+LhBT9GqXpaG3emuguxSGMkff4RtPv/3DOFNk9KAIK8i +7GWCBjW5GF7mkTdQtYqVi1d87jeuGZ1InF1FlIZaswWGeG6Emml+Gxa50Z7Kpmc7 +f2vZlg9E8kmbRttCVUx4kx5PxKOI6s/ebKTFbHO+ZXJtm8MyOTrAJLfnFo4SUA90 +zX6CzyP1qu1/qdf9+kT0o0JeEsqg+0f4yhp3x/xH5OsAlUpRHvRr2aB3ZYi/4Vwj +53fMNXk= +-----END CERTIFICATE----- diff --git a/tests/test_api.py b/tests/test_api.py index ae194af7fd..3b2a9c8fb7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ import pytest from unittest import mock +import sentry_sdk from sentry_sdk import ( capture_exception, continue_trace, @@ -195,3 +196,19 @@ def test_push_scope_deprecation(): with pytest.warns(DeprecationWarning): with push_scope(): ... + + +def test_init_context_manager_deprecation(): + with pytest.warns(DeprecationWarning): + with sentry_sdk.init(): + ... + + +def test_init_enter_deprecation(): + with pytest.warns(DeprecationWarning): + sentry_sdk.init().__enter__() + + +def test_init_exit_deprecation(): + with pytest.warns(DeprecationWarning): + sentry_sdk.init().__exit__(None, None, None) diff --git a/tests/test_basics.py b/tests/test_basics.py index c9d80118c2..ad20bb9fd5 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -8,6 +8,7 @@ import pytest from sentry_sdk.client import Client +from sentry_sdk.utils import datetime_from_isoformat from tests.conftest import patch_start_tracing_child import sentry_sdk @@ -28,29 +29,17 @@ from sentry_sdk.integrations import ( _AUTO_ENABLING_INTEGRATIONS, _DEFAULT_INTEGRATIONS, + DidNotEnable, Integration, setup_integrations, ) from sentry_sdk.integrations.logging import LoggingIntegration -from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.utils import get_sdk_name, reraise from sentry_sdk.tracing_utils import has_tracing_enabled -def _redis_installed(): # type: () -> bool - """ - Determines whether Redis is installed. - """ - try: - import redis # noqa: F401 - except ImportError: - return False - - return True - - class NoOpIntegration(Integration): """ A simple no-op integration for testing purposes. @@ -89,20 +78,35 @@ def error_processor(event, exc_info): assert event["exception"]["values"][0]["value"] == "aha! whatever" +class ModuleImportErrorSimulator: + def __init__(self, modules, error_cls=DidNotEnable): + self.modules = modules + self.error_cls = error_cls + for sys_module in list(sys.modules.keys()): + if any(sys_module.startswith(module) for module in modules): + del sys.modules[sys_module] + + def find_spec(self, fullname, _path, _target=None): + if fullname in self.modules: + raise self.error_cls("Test import failure for %s" % fullname) + + def __enter__(self): + # WARNING: We need to be first to avoid pytest messing with local imports + sys.meta_path.insert(0, self) + + def __exit__(self, *_args): + sys.meta_path.remove(self) + + def test_auto_enabling_integrations_catches_import_error(sentry_init, caplog): caplog.set_level(logging.DEBUG) - redis_index = _AUTO_ENABLING_INTEGRATIONS.index( - "sentry_sdk.integrations.redis.RedisIntegration" - ) # noqa: N806 - sentry_init(auto_enabling_integrations=True, debug=True) + with ModuleImportErrorSimulator( + [i.rsplit(".", 1)[0] for i in _AUTO_ENABLING_INTEGRATIONS] + ): + sentry_init(auto_enabling_integrations=True, debug=True) for import_string in _AUTO_ENABLING_INTEGRATIONS: - # Ignore redis in the test case, because it does not raise a DidNotEnable - # exception on import; rather, it raises the exception upon enabling. - if _AUTO_ENABLING_INTEGRATIONS[redis_index] == import_string: - continue - assert any( record.message.startswith( "Did not import default integration {}:".format(import_string) @@ -397,11 +401,12 @@ def test_breadcrumbs(sentry_init, capture_events): def test_breadcrumb_ordering(sentry_init, capture_events): sentry_init() events = capture_events() + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) timestamps = [ - datetime.datetime.now() - datetime.timedelta(days=10), - datetime.datetime.now() - datetime.timedelta(days=8), - datetime.datetime.now() - datetime.timedelta(days=12), + now - datetime.timedelta(days=10), + now - datetime.timedelta(days=8), + now - datetime.timedelta(days=12), ] for timestamp in timestamps: @@ -417,10 +422,48 @@ def test_breadcrumb_ordering(sentry_init, capture_events): assert len(event["breadcrumbs"]["values"]) == len(timestamps) timestamps_from_event = [ - datetime.datetime.strptime( - x["timestamp"].replace("Z", ""), "%Y-%m-%dT%H:%M:%S.%f" + datetime_from_isoformat(x["timestamp"]) for x in event["breadcrumbs"]["values"] + ] + assert timestamps_from_event == sorted(timestamps) + + +def test_breadcrumb_ordering_different_types(sentry_init, capture_events): + sentry_init() + events = capture_events() + now = datetime.datetime.now(datetime.timezone.utc) + + timestamps = [ + now - datetime.timedelta(days=10), + now - datetime.timedelta(days=8), + now.replace(microsecond=0) - datetime.timedelta(days=12), + now - datetime.timedelta(days=9), + now - datetime.timedelta(days=13), + now.replace(microsecond=0) - datetime.timedelta(days=11), + ] + + breadcrumb_timestamps = [ + timestamps[0], + timestamps[1].isoformat(), + datetime.datetime.strftime(timestamps[2], "%Y-%m-%dT%H:%M:%S") + "Z", + datetime.datetime.strftime(timestamps[3], "%Y-%m-%dT%H:%M:%S.%f") + "+00:00", + datetime.datetime.strftime(timestamps[4], "%Y-%m-%dT%H:%M:%S.%f") + "+0000", + datetime.datetime.strftime(timestamps[5], "%Y-%m-%dT%H:%M:%S.%f") + "-0000", + ] + + for i, timestamp in enumerate(timestamps): + add_breadcrumb( + message="Authenticated at %s" % timestamp, + category="auth", + level="info", + timestamp=breadcrumb_timestamps[i], ) - for x in event["breadcrumbs"]["values"] + + capture_exception(ValueError()) + (event,) = events + + assert len(event["breadcrumbs"]["values"]) == len(timestamps) + timestamps_from_event = [ + datetime_from_isoformat(x["timestamp"]) for x in event["breadcrumbs"]["values"] ] assert timestamps_from_event == sorted(timestamps) @@ -843,13 +886,6 @@ def test_functions_to_trace_with_class(sentry_init, capture_events): assert event["spans"][1]["description"] == "tests.test_basics.WorldGreeter.greet" -@pytest.mark.skipif(_redis_installed(), reason="skipping because redis is installed") -def test_redis_disabled_when_not_installed(sentry_init): - sentry_init() - - assert sentry_sdk.get_client().get_integration(RedisIntegration) is None - - def test_multiple_setup_integrations_calls(): first_call_return = setup_integrations([NoOpIntegration()], with_defaults=False) assert first_call_return == {NoOpIntegration.identifier: NoOpIntegration()} @@ -955,3 +991,46 @@ def test_hub_current_deprecation_warning(): def test_hub_main_deprecation_warnings(): with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning): Hub.main + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes(sentry_init, capture_events): + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("Test 123") + e.add_note("another note") + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nTest 123\nanother note" + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes_safe_str(sentry_init, capture_events): + class Note2: + def __repr__(self): + raise TypeError + + def __str__(self): + raise TypeError + + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("note 1") + e.__notes__.append(Note2()) # type: ignore + e.add_note("note 3") + e.__notes__.append(2) # type: ignore + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nnote 1\nnote 3" diff --git a/tests/test_client.py b/tests/test_client.py index f6c2cec05c..450e19603f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -21,12 +21,14 @@ capture_event, set_tag, ) +from sentry_sdk.spotlight import DEFAULT_SPOTLIGHT_URL from sentry_sdk.utils import capture_internal_exception from sentry_sdk.integrations.executing import ExecutingIntegration from sentry_sdk.transport import Transport from sentry_sdk.serializer import MAX_DATABAG_BREADTH from sentry_sdk.consts import DEFAULT_MAX_BREADCRUMBS, DEFAULT_MAX_VALUE_LENGTH -from sentry_sdk._types import TYPE_CHECKING + +from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable @@ -244,7 +246,10 @@ def test_transport_option(monkeypatch): }, ], ) -def test_proxy(monkeypatch, testcase): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_proxy(monkeypatch, testcase, http2): if testcase["env_http_proxy"] is not None: monkeypatch.setenv("HTTP_PROXY", testcase["env_http_proxy"]) if testcase["env_https_proxy"] is not None: @@ -254,6 +259,9 @@ def test_proxy(monkeypatch, testcase): kwargs = {} + if http2: + kwargs["_experiments"] = {"transport_http2": True} + if testcase["arg_http_proxy"] is not None: kwargs["http_proxy"] = testcase["arg_http_proxy"] if testcase["arg_https_proxy"] is not None: @@ -263,13 +271,31 @@ def test_proxy(monkeypatch, testcase): client = Client(testcase["dsn"], **kwargs) + proxy = getattr( + client.transport._pool, + "proxy", + getattr(client.transport._pool, "_proxy_url", None), + ) if testcase["expected_proxy_scheme"] is None: - assert client.transport._pool.proxy is None + assert proxy is None else: - assert client.transport._pool.proxy.scheme == testcase["expected_proxy_scheme"] + scheme = ( + proxy.scheme.decode("ascii") + if isinstance(proxy.scheme, bytes) + else proxy.scheme + ) + assert scheme == testcase["expected_proxy_scheme"] if testcase.get("arg_proxy_headers") is not None: - assert client.transport._pool.proxy_headers == testcase["arg_proxy_headers"] + proxy_headers = ( + dict( + (k.decode("ascii"), v.decode("ascii")) + for k, v in client.transport._pool._proxy_headers + ) + if http2 + else client.transport._pool.proxy_headers + ) + assert proxy_headers == testcase["arg_proxy_headers"] @pytest.mark.parametrize( @@ -279,68 +305,79 @@ def test_proxy(monkeypatch, testcase): "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "http://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": False, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks4a://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks4://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks5h://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks5://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks4a://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks4://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks5h://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks5://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, ], ) -def test_socks_proxy(testcase): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_socks_proxy(testcase, http2): kwargs = {} + if http2: + kwargs["_experiments"] = {"transport_http2": True} + if testcase["arg_http_proxy"] is not None: kwargs["http_proxy"] = testcase["arg_http_proxy"] if testcase["arg_https_proxy"] is not None: kwargs["https_proxy"] = testcase["arg_https_proxy"] client = Client(testcase["dsn"], **kwargs) - assert str(type(client.transport._pool)) == testcase["expected_proxy_class"] + assert ("socks" in str(type(client.transport._pool)).lower()) == testcase[ + "should_be_socks_proxy" + ], ( + f"Expected {kwargs} to result in SOCKS == {testcase['should_be_socks_proxy']}" + f"but got {str(type(client.transport._pool))}" + ) def test_simple_transport(sentry_init): @@ -531,7 +568,17 @@ def test_capture_event_works(sentry_init): @pytest.mark.parametrize("num_messages", [10, 20]) -def test_atexit(tmpdir, monkeypatch, num_messages): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_atexit(tmpdir, monkeypatch, num_messages, http2): + if http2: + options = '_experiments={"transport_http2": True}' + transport = "Http2Transport" + else: + options = "" + transport = "HttpTransport" + app = tmpdir.join("app.py") app.write( dedent( @@ -545,13 +592,13 @@ def capture_envelope(self, envelope): message = event.get("message", "") print(message) - transport.HttpTransport.capture_envelope = capture_envelope - init("http://foobar@localhost/123", shutdown_timeout={num_messages}) + transport.{transport}.capture_envelope = capture_envelope + init("http://foobar@localhost/123", shutdown_timeout={num_messages}, {options}) for _ in range({num_messages}): capture_message("HI") """.format( - num_messages=num_messages + transport=transport, options=options, num_messages=num_messages ) ) ) @@ -944,6 +991,39 @@ def __repr__(self): assert frame["vars"]["environ"] == {"a": ""} +def test_custom_repr_on_vars(sentry_init, capture_events): + class Foo: + pass + + class Fail: + pass + + def custom_repr(value): + if isinstance(value, Foo): + return "custom repr" + elif isinstance(value, Fail): + raise ValueError("oops") + else: + return None + + sentry_init(custom_repr=custom_repr) + events = capture_events() + + try: + my_vars = {"foo": Foo(), "fail": Fail(), "normal": 42} + 1 / 0 + except ZeroDivisionError: + capture_exception() + + (event,) = events + (exception,) = event["exception"]["values"] + (frame,) = exception["stacktrace"]["frames"] + my_vars = frame["vars"]["my_vars"] + assert my_vars["foo"] == "custom repr" + assert my_vars["normal"] == "42" + assert "Fail object" in my_vars["fail"] + + @pytest.mark.parametrize( "dsn", [ @@ -1064,6 +1144,47 @@ def test_debug_option( assert "something is wrong" not in caplog.text +@pytest.mark.parametrize( + "client_option,env_var_value,spotlight_url_expected", + [ + (None, None, None), + (None, "", None), + (None, "F", None), + (False, None, None), + (False, "", None), + (False, "t", None), + (None, "t", DEFAULT_SPOTLIGHT_URL), + (None, "1", DEFAULT_SPOTLIGHT_URL), + (True, None, DEFAULT_SPOTLIGHT_URL), + (True, "http://localhost:8080/slurp", DEFAULT_SPOTLIGHT_URL), + ("http://localhost:8080/slurp", "f", "http://localhost:8080/slurp"), + (None, "http://localhost:8080/slurp", "http://localhost:8080/slurp"), + ], +) +def test_spotlight_option( + sentry_init, + monkeypatch, + client_option, + env_var_value, + spotlight_url_expected, +): + if env_var_value is None: + monkeypatch.delenv("SENTRY_SPOTLIGHT", raising=False) + else: + monkeypatch.setenv("SENTRY_SPOTLIGHT", env_var_value) + + if client_option is None: + sentry_init() + else: + sentry_init(spotlight=client_option) + + client = sentry_sdk.get_client() + url = client.spotlight.url if client.spotlight else None + assert ( + url == spotlight_url_expected + ), f"With config {client_option} and env {env_var_value}" + + class IssuesSamplerTestConfig: def __init__( self, diff --git a/tests/test_dsc.py b/tests/test_dsc.py new file mode 100644 index 0000000000..3b8cff5baf --- /dev/null +++ b/tests/test_dsc.py @@ -0,0 +1,322 @@ +""" +This tests test for the correctness of the dynamic sampling context (DSC) in the trace header of envelopes. + +The DSC is defined here: +https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#dsc-specification + +The DSC is propagated between service using a header called "baggage". +This is not tested in this file. +""" + +import pytest + +import sentry_sdk +import sentry_sdk.client + + +def test_dsc_head_of_trace(sentry_init, capture_envelopes): + """ + Our service is the head of the trace (it starts a new trace) + and sends a transaction event to Sentry. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # We start a new transaction + with sentry_sdk.start_transaction(name="foo"): + pass + + assert len(envelopes) == 1 + + transaction_envelope = envelopes[0] + envelope_trace_header = transaction_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "1.0" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "foo" + + +def test_dsc_continuation_of_trace(sentry_init, capture_envelopes): + """ + Another service calls our service and passes tracing information to us. + Our service is continuing the trace and sends a transaction event to Sentry. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # This is what the upstream service sends us + sentry_trace = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1" + baggage = ( + "other-vendor-value-1=foo;bar;baz, " + "sentry-trace_id=771a43a4192642f0b136d5159a501700, " + "sentry-public_key=frontendpublickey, " + "sentry-sample_rate=0.01337, " + "sentry-sampled=true, " + "sentry-release=myfrontend@1.2.3, " + "sentry-environment=bird, " + "sentry-transaction=bar, " + "other-vendor-value-2=foo;bar;" + ) + incoming_http_headers = { + "HTTP_SENTRY_TRACE": sentry_trace, + "HTTP_BAGGAGE": baggage, + } + + # We continue the incoming trace and start a new transaction + transaction = sentry_sdk.continue_trace(incoming_http_headers) + with sentry_sdk.start_transaction(transaction, name="foo"): + pass + + assert len(envelopes) == 1 + + transaction_envelope = envelopes[0] + envelope_trace_header = transaction_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + assert envelope_trace_header["trace_id"] == "771a43a4192642f0b136d5159a501700" + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "frontendpublickey" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "0.01337" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myfrontend@1.2.3" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "bird" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "bar" + + +def test_dsc_issue(sentry_init, capture_envelopes): + """ + Our service is a standalone service that does not have tracing enabled. Just uses Sentry for error reporting. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + ) + envelopes = capture_envelopes() + + # No transaction is started, just an error is captured + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 1 + + error_envelope = envelopes[0] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" not in envelope_trace_header + + assert "sampled" not in envelope_trace_header + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" not in envelope_trace_header + + +def test_dsc_issue_with_tracing(sentry_init, capture_envelopes): + """ + Our service has tracing enabled and an error occurs in an transaction. + Envelopes containing errors also have the same DSC than the transaction envelopes. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # We start a new transaction and an error occurs + with sentry_sdk.start_transaction(name="foo"): + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 2 + + error_envelope, transaction_envelope = envelopes + + assert error_envelope.headers["trace"] == transaction_envelope.headers["trace"] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" in envelope_trace_header + assert envelope_trace_header["sample_rate"] == "1.0" + assert type(envelope_trace_header["sample_rate"]) == str + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "foo" + + +@pytest.mark.parametrize( + "traces_sample_rate", + [ + 0, # no traces will be started, but if incoming traces will be continued (by our instrumentations, not happening in this test) + None, # no tracing at all. This service will never create transactions. + ], +) +def test_dsc_issue_twp(sentry_init, capture_envelopes, traces_sample_rate): + """ + Our service does not have tracing enabled, but we receive tracing information from an upstream service. + Error envelopes still contain a DCS. This is called "tracing without performance" or TWP for short. + + This way if I have three services A, B, and C, and A and C have tracing enabled, but B does not, + we still can see the full trace in Sentry, and associate errors send by service B to Sentry. + (This test would be service B in this scenario) + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=traces_sample_rate, + ) + envelopes = capture_envelopes() + + # This is what the upstream service sends us + sentry_trace = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1" + baggage = ( + "other-vendor-value-1=foo;bar;baz, " + "sentry-trace_id=771a43a4192642f0b136d5159a501700, " + "sentry-public_key=frontendpublickey, " + "sentry-sample_rate=0.01337, " + "sentry-sampled=true, " + "sentry-release=myfrontend@1.2.3, " + "sentry-environment=bird, " + "sentry-transaction=bar, " + "other-vendor-value-2=foo;bar;" + ) + incoming_http_headers = { + "HTTP_SENTRY_TRACE": sentry_trace, + "HTTP_BAGGAGE": baggage, + } + + # We continue the trace (meaning: saving the incoming trace information on the scope) + # but in this test, we do not start a transaction. + sentry_sdk.continue_trace(incoming_http_headers) + + # No transaction is started, just an error is captured + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 1 + + error_envelope = envelopes[0] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + assert envelope_trace_header["trace_id"] == "771a43a4192642f0b136d5159a501700" + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "frontendpublickey" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "0.01337" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myfrontend@1.2.3" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "bird" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "bar" diff --git a/tests/test_flag_utils.py b/tests/test_flag_utils.py new file mode 100644 index 0000000000..3fa4f3abfe --- /dev/null +++ b/tests/test_flag_utils.py @@ -0,0 +1,43 @@ +from sentry_sdk.flag_utils import FlagBuffer + + +def test_flag_tracking(): + """Assert the ring buffer works.""" + buffer = FlagBuffer(capacity=3) + buffer.set("a", True) + flags = buffer.get() + assert len(flags) == 1 + assert flags == [{"flag": "a", "result": True}] + + buffer.set("b", True) + flags = buffer.get() + assert len(flags) == 2 + assert flags == [{"flag": "a", "result": True}, {"flag": "b", "result": True}] + + buffer.set("c", True) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "a", "result": True}, + {"flag": "b", "result": True}, + {"flag": "c", "result": True}, + ] + + buffer.set("d", False) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "b", "result": True}, + {"flag": "c", "result": True}, + {"flag": "d", "result": False}, + ] + + buffer.set("e", False) + buffer.set("f", False) + flags = buffer.get() + assert len(flags) == 3 + assert flags == [ + {"flag": "d", "result": False}, + {"flag": "e", "result": False}, + {"flag": "f", "result": False}, + ] diff --git a/tests/test_full_stack_frames.py b/tests/test_full_stack_frames.py new file mode 100644 index 0000000000..ad0826cd10 --- /dev/null +++ b/tests/test_full_stack_frames.py @@ -0,0 +1,103 @@ +import sentry_sdk + + +def test_full_stack_frames_default(sentry_init, capture_events): + sentry_init() + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + + +def test_full_stack_frames_enabled(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) > 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + assert frames[-4]["function"] == "test_full_stack_frames_enabled" + + +def test_full_stack_frames_enabled_truncated(sentry_init, capture_events): + sentry_init( + add_full_stack=True, + max_stack_frames=3, + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 3 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" + assert frames[-3]["function"] == "foo" + + +def test_full_stack_frames_default_no_truncation_happening(sentry_init, capture_events): + sentry_init( + max_stack_frames=1, # this is ignored if add_full_stack=False (which is the default) + ) + events = capture_events() + + def foo(): + try: + bar() + except Exception as e: + sentry_sdk.capture_exception(e) + + def bar(): + raise Exception("This is a test exception") + + foo() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + + assert len(frames) == 2 + assert frames[-1]["function"] == "bar" + assert frames[-2]["function"] == "foo" diff --git a/tests/test_lru_cache.py b/tests/test_lru_cache.py index 5343e76169..cab9bbc7eb 100644 --- a/tests/test_lru_cache.py +++ b/tests/test_lru_cache.py @@ -1,4 +1,5 @@ import pytest +from copy import copy from sentry_sdk._lru_cache import LRUCache @@ -35,3 +36,43 @@ def test_cache_eviction(): cache.set(4, 4) assert cache.get(3) is None assert cache.get(4) == 4 + + +def test_cache_miss(): + cache = LRUCache(1) + assert cache.get(0) is None + + +def test_cache_set_overwrite(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(0, 1) + assert cache.get(0) == 1 + + +def test_cache_get_all(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(1, 1) + cache.set(2, 2) + cache.set(3, 3) + assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] + cache.get(1) + assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] + + +def test_cache_copy(): + cache = LRUCache(3) + cache.set(0, 0) + cache.set(1, 1) + + copied = copy(cache) + cache.set(2, 2) + cache.set(3, 3) + assert copied.get_all() == [(0, 0), (1, 1)] + assert cache.get_all() == [(1, 1), (2, 2), (3, 3)] + + copied = copy(cache) + cache.get(1) + assert copied.get_all() == [(1, 1), (2, 2), (3, 3)] + assert cache.get_all() == [(2, 2), (3, 3), (1, 1)] diff --git a/tests/test_scope.py b/tests/test_scope.py index 0dfa155d11..a03eb07a99 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -19,10 +19,6 @@ ) -SLOTS_NOT_COPIED = {"client"} -"""__slots__ that are not copied when copying a Scope object.""" - - def test_copying(): s1 = Scope() s1.fingerprint = {} @@ -43,7 +39,7 @@ def test_all_slots_copied(): scope_copy = copy.copy(scope) # Check all attributes are copied - for attr in set(Scope.__slots__) - SLOTS_NOT_COPIED: + for attr in set(Scope.__slots__): assert getattr(scope_copy, attr) == getattr(scope, attr) @@ -811,6 +807,24 @@ def test_should_send_default_pii_false(sentry_init): assert should_send_default_pii() is False +def test_should_send_default_pii_default_false(sentry_init): + sentry_init() + + assert should_send_default_pii() is False + + +def test_should_send_default_pii_false_with_dsn_and_spotlight(sentry_init): + sentry_init(dsn="http://key@localhost/1", spotlight=True) + + assert should_send_default_pii() is False + + +def test_should_send_default_pii_true_without_dsn_and_spotlight(sentry_init): + sentry_init(spotlight=True) + + assert should_send_default_pii() is True + + def test_set_tags(): scope = Scope() scope.set_tags({"tag1": "value1", "tag2": "value2"}) diff --git a/tests/test_scrubber.py b/tests/test_scrubber.py index 2c4bd3aa90..2c462153dd 100644 --- a/tests/test_scrubber.py +++ b/tests/test_scrubber.py @@ -25,6 +25,7 @@ def test_request_scrubbing(sentry_init, capture_events): "COOKIE": "secret", "authorization": "Bearer bla", "ORIGIN": "google.com", + "ip_address": "127.0.0.1", }, "cookies": { "sessionid": "secret", @@ -45,6 +46,7 @@ def test_request_scrubbing(sentry_init, capture_events): "COOKIE": "[Filtered]", "authorization": "[Filtered]", "ORIGIN": "google.com", + "ip_address": "[Filtered]", }, "cookies": {"sessionid": "[Filtered]", "foo": "bar"}, "data": {"token": "[Filtered]", "foo": "bar"}, @@ -54,12 +56,39 @@ def test_request_scrubbing(sentry_init, capture_events): "headers": { "COOKIE": {"": {"rem": [["!config", "s"]]}}, "authorization": {"": {"rem": [["!config", "s"]]}}, + "ip_address": {"": {"rem": [["!config", "s"]]}}, }, "cookies": {"sessionid": {"": {"rem": [["!config", "s"]]}}}, "data": {"token": {"": {"rem": [["!config", "s"]]}}}, } +def test_ip_address_not_scrubbed_when_pii_enabled(sentry_init, capture_events): + sentry_init(send_default_pii=True) + events = capture_events() + + try: + 1 / 0 + except ZeroDivisionError: + ev, _hint = event_from_exception(sys.exc_info()) + + ev["request"] = {"headers": {"COOKIE": "secret", "ip_address": "127.0.0.1"}} + + capture_event(ev) + + (event,) = events + + assert event["request"] == { + "headers": {"COOKIE": "[Filtered]", "ip_address": "127.0.0.1"} + } + + assert event["_meta"]["request"] == { + "headers": { + "COOKIE": {"": {"rem": [["!config", "s"]]}}, + } + } + + def test_stack_var_scrubbing(sentry_init, capture_events): sentry_init() events = capture_events() @@ -117,7 +146,7 @@ def test_span_data_scrubbing(sentry_init, capture_events): events = capture_events() with start_transaction(name="hi"): - with start_span(op="foo", description="bar") as span: + with start_span(op="foo", name="bar") as span: span.set_data("password", "secret") span.set_data("datafoo", "databar") @@ -131,11 +160,16 @@ def test_span_data_scrubbing(sentry_init, capture_events): def test_custom_denylist(sentry_init, capture_events): - sentry_init(event_scrubber=EventScrubber(denylist=["my_sensitive_var"])) + sentry_init( + event_scrubber=EventScrubber( + denylist=["my_sensitive_var"], pii_denylist=["my_pii_var"] + ) + ) events = capture_events() try: my_sensitive_var = "secret" # noqa + my_pii_var = "jane.doe" # noqa safe = "keepthis" # noqa 1 / 0 except ZeroDivisionError: @@ -146,6 +180,7 @@ def test_custom_denylist(sentry_init, capture_events): frames = event["exception"]["values"][0]["stacktrace"]["frames"] (frame,) = frames assert frame["vars"]["my_sensitive_var"] == "[Filtered]" + assert frame["vars"]["my_pii_var"] == "[Filtered]" assert frame["vars"]["safe"] == "'keepthis'" meta = event["_meta"]["exception"]["values"]["0"]["stacktrace"]["frames"]["0"][ @@ -153,6 +188,7 @@ def test_custom_denylist(sentry_init, capture_events): ] assert meta == { "my_sensitive_var": {"": {"rem": [["!config", "s"]]}}, + "my_pii_var": {"": {"rem": [["!config", "s"]]}}, } @@ -187,3 +223,20 @@ def test_recursive_event_scrubber(sentry_init, capture_events): (event,) = events assert event["extra"]["deep"]["deeper"][0]["deepest"]["password"] == "'[Filtered]'" + + +def test_recursive_scrubber_does_not_override_original(sentry_init, capture_events): + sentry_init(event_scrubber=EventScrubber(recursive=True)) + events = capture_events() + + data = {"csrf": "secret"} + try: + raise RuntimeError("An error") + except Exception: + capture_exception() + + (event,) = events + frames = event["exception"]["values"][0]["stacktrace"]["frames"] + (frame,) = frames + assert data["csrf"] == "secret" + assert frame["vars"]["data"]["csrf"] == "[Filtered]" diff --git a/tests/test_serializer.py b/tests/test_serializer.py index a3ead112a7..2f158097bd 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -114,6 +114,31 @@ def test_custom_mapping_doesnt_mess_with_mock(extra_normalizer): assert len(m.mock_calls) == 0 +def test_custom_repr(extra_normalizer): + class Foo: + pass + + def custom_repr(value): + if isinstance(value, Foo): + return "custom" + else: + return value + + result = extra_normalizer({"foo": Foo(), "string": "abc"}, custom_repr=custom_repr) + assert result == {"foo": "custom", "string": "abc"} + + +def test_custom_repr_graceful_fallback_to_safe_repr(extra_normalizer): + class Foo: + pass + + def custom_repr(value): + raise ValueError("oops") + + result = extra_normalizer({"foo": Foo()}, custom_repr=custom_repr) + assert "Foo object" in result["foo"] + + def test_trim_databag_breadth(body_normalizer): data = { "key{}".format(i): "value{}".format(i) for i in range(MAX_DATABAG_BREADTH + 10) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index c10b9262ce..9cad0b7252 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -1,7 +1,7 @@ from unittest import mock import sentry_sdk -from sentry_sdk.sessions import auto_session_tracking +from sentry_sdk.sessions import auto_session_tracking, track_session def sorted_aggregates(item): @@ -50,10 +50,51 @@ def test_aggregates(sentry_init, capture_envelopes): ) envelopes = capture_envelopes() + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + try: + scope.set_user({"id": "42"}) + raise Exception("all is wrong") + except Exception: + sentry_sdk.capture_exception() + + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + pass + + sentry_sdk.get_isolation_scope().start_session(session_mode="request") + sentry_sdk.get_isolation_scope().end_session() + sentry_sdk.flush() + + assert len(envelopes) == 2 + assert envelopes[0].get_event() is not None + + sess = envelopes[1] + assert len(sess.items) == 1 + sess_event = sess.items[0].payload.json + assert sess_event["attrs"] == { + "release": "fun-release", + "environment": "not-fun-env", + } + + aggregates = sorted_aggregates(sess_event) + assert len(aggregates) == 1 + assert aggregates[0]["exited"] == 2 + assert aggregates[0]["errored"] == 1 + + +def test_aggregates_deprecated( + sentry_init, capture_envelopes, suppress_deprecation_warnings +): + sentry_init( + release="fun-release", + environment="not-fun-env", + ) + envelopes = capture_envelopes() + with auto_session_tracking(session_mode="request"): with sentry_sdk.new_scope() as scope: try: - scope = sentry_sdk.get_current_scope() scope.set_user({"id": "42"}) raise Exception("all is wrong") except Exception: @@ -91,6 +132,39 @@ def test_aggregates_explicitly_disabled_session_tracking_request_mode( ) envelopes = capture_envelopes() + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + try: + raise Exception("all is wrong") + except Exception: + sentry_sdk.capture_exception() + + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + pass + + sentry_sdk.get_isolation_scope().start_session(session_mode="request") + sentry_sdk.get_isolation_scope().end_session() + sentry_sdk.flush() + + sess = envelopes[1] + assert len(sess.items) == 1 + sess_event = sess.items[0].payload.json + + aggregates = sorted_aggregates(sess_event) + assert len(aggregates) == 1 + assert aggregates[0]["exited"] == 1 + assert "errored" not in aggregates[0] + + +def test_aggregates_explicitly_disabled_session_tracking_request_mode_deprecated( + sentry_init, capture_envelopes, suppress_deprecation_warnings +): + sentry_init( + release="fun-release", environment="not-fun-env", auto_session_tracking=False + ) + envelopes = capture_envelopes() + with auto_session_tracking(session_mode="request"): with sentry_sdk.new_scope(): try: @@ -121,6 +195,37 @@ def test_no_thread_on_shutdown_no_errors(sentry_init): environment="not-fun-env", ) + # make it seem like the interpreter is shutting down + with mock.patch( + "threading.Thread.start", + side_effect=RuntimeError("can't create new thread at interpreter shutdown"), + ): + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + try: + raise Exception("all is wrong") + except Exception: + sentry_sdk.capture_exception() + + with sentry_sdk.isolation_scope() as scope: + with track_session(scope, session_mode="request"): + pass + + sentry_sdk.get_isolation_scope().start_session(session_mode="request") + sentry_sdk.get_isolation_scope().end_session() + sentry_sdk.flush() + + # If we reach this point without error, the test is successful. + + +def test_no_thread_on_shutdown_no_errors_deprecated( + sentry_init, suppress_deprecation_warnings +): + sentry_init( + release="fun-release", + environment="not-fun-env", + ) + # make it seem like the interpreter is shutting down with mock.patch( "threading.Thread.start", @@ -139,3 +244,5 @@ def test_no_thread_on_shutdown_no_errors(sentry_init): sentry_sdk.get_isolation_scope().start_session(session_mode="request") sentry_sdk.get_isolation_scope().end_session() sentry_sdk.flush() + + # If we reach this point without error, the test is successful. diff --git a/tests/test_tracing_utils.py b/tests/test_tracing_utils.py new file mode 100644 index 0000000000..5c1f70516d --- /dev/null +++ b/tests/test_tracing_utils.py @@ -0,0 +1,117 @@ +from dataclasses import asdict, dataclass +from typing import Optional, List + +from sentry_sdk.tracing_utils import _should_be_included, Baggage +import pytest + + +def id_function(val): + # type: (object) -> str + if isinstance(val, ShouldBeIncludedTestCase): + return val.id + + +@dataclass(frozen=True) +class ShouldBeIncludedTestCase: + id: str + is_sentry_sdk_frame: bool + namespace: Optional[str] = None + in_app_include: Optional[List[str]] = None + in_app_exclude: Optional[List[str]] = None + abs_path: Optional[str] = None + project_root: Optional[str] = None + + +@pytest.mark.parametrize( + "test_case, expected", + [ + ( + ShouldBeIncludedTestCase( + id="Frame from Sentry SDK", + is_sentry_sdk_frame=True, + ), + False, + ), + ( + ShouldBeIncludedTestCase( + id="Frame from Django installed in virtualenv inside project root", + is_sentry_sdk_frame=False, + abs_path="/home/username/some_project/.venv/lib/python3.12/site-packages/django/db/models/sql/compiler", + project_root="/home/username/some_project", + namespace="django.db.models.sql.compiler", + in_app_include=["django"], + ), + True, + ), + ( + ShouldBeIncludedTestCase( + id="Frame from project", + is_sentry_sdk_frame=False, + abs_path="/home/username/some_project/some_project/__init__.py", + project_root="/home/username/some_project", + namespace="some_project", + ), + True, + ), + ( + ShouldBeIncludedTestCase( + id="Frame from project module in `in_app_exclude`", + is_sentry_sdk_frame=False, + abs_path="/home/username/some_project/some_project/exclude_me/some_module.py", + project_root="/home/username/some_project", + namespace="some_project.exclude_me.some_module", + in_app_exclude=["some_project.exclude_me"], + ), + False, + ), + ( + ShouldBeIncludedTestCase( + id="Frame from system-wide installed Django", + is_sentry_sdk_frame=False, + abs_path="/usr/lib/python3.12/site-packages/django/db/models/sql/compiler", + project_root="/home/username/some_project", + namespace="django.db.models.sql.compiler", + ), + False, + ), + ( + ShouldBeIncludedTestCase( + id="Frame from system-wide installed Django with `django` in `in_app_include`", + is_sentry_sdk_frame=False, + abs_path="/usr/lib/python3.12/site-packages/django/db/models/sql/compiler", + project_root="/home/username/some_project", + namespace="django.db.models.sql.compiler", + in_app_include=["django"], + ), + True, + ), + ], + ids=id_function, +) +def test_should_be_included(test_case, expected): + # type: (ShouldBeIncludedTestCase, bool) -> None + """Checking logic, see: https://github.com/getsentry/sentry-python/issues/3312""" + kwargs = asdict(test_case) + kwargs.pop("id") + assert _should_be_included(**kwargs) == expected + + +@pytest.mark.parametrize( + ("header", "expected"), + ( + ("", ""), + ("foo=bar", "foo=bar"), + (" foo=bar, baz = qux ", " foo=bar, baz = qux "), + ("sentry-trace_id=123", ""), + (" sentry-trace_id = 123 ", ""), + ("sentry-trace_id=123,sentry-public_key=456", ""), + ("foo=bar,sentry-trace_id=123", "foo=bar"), + ("foo=bar,sentry-trace_id=123,baz=qux", "foo=bar,baz=qux"), + ( + "foo=bar,sentry-trace_id=123,baz=qux,sentry-public_key=456", + "foo=bar,baz=qux", + ), + ), +) +def test_strip_sentry_baggage(header, expected): + assert Baggage.strip_sentry_baggage(header) == expected diff --git a/tests/test_transport.py b/tests/test_transport.py index 2e2ad3c4cd..d24bea0491 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -2,15 +2,23 @@ import pickle import gzip import io +import os import socket +import sys from collections import defaultdict, namedtuple from datetime import datetime, timedelta, timezone from unittest import mock +import brotli import pytest from pytest_localserver.http import WSGIServer from werkzeug.wrappers import Request, Response +try: + import gevent +except ImportError: + gevent = None + import sentry_sdk from sentry_sdk import ( Client, @@ -20,6 +28,7 @@ get_isolation_scope, Hub, ) +from sentry_sdk._compat import PY37, PY38 from sentry_sdk.envelope import Envelope, Item, parse_json from sentry_sdk.transport import ( KEEP_ALIVE_SOCKET_OPTIONS, @@ -52,9 +61,13 @@ def __call__(self, environ, start_response): """ request = Request(environ) event = envelope = None - if request.headers.get("content-encoding") == "gzip": + content_encoding = request.headers.get("content-encoding") + if content_encoding == "gzip": rdr = gzip.GzipFile(fileobj=io.BytesIO(request.data)) compressed = True + elif content_encoding == "br": + rdr = io.BytesIO(brotli.decompress(request.data)) + compressed = True else: rdr = io.BytesIO(request.data) compressed = False @@ -91,7 +104,7 @@ def make_client(request, capturing_server): def inner(**kwargs): return Client( "http://foobar@{}/132".format(capturing_server.url[len("http://") :]), - **kwargs + **kwargs, ) return inner @@ -115,7 +128,16 @@ def mock_transaction_envelope(span_count): @pytest.mark.parametrize("debug", (True, False)) @pytest.mark.parametrize("client_flush_method", ["close", "flush"]) @pytest.mark.parametrize("use_pickle", (True, False)) -@pytest.mark.parametrize("compressionlevel", (0, 9)) +@pytest.mark.parametrize("compression_level", (0, 9, None)) +@pytest.mark.parametrize( + "compression_algo", + ( + ("gzip", "br", "", None) + if PY37 or gevent is None + else ("gzip", "", None) + ), +) +@pytest.mark.parametrize("http2", [True, False] if PY38 else [False]) def test_transport_works( capturing_server, request, @@ -125,15 +147,26 @@ def test_transport_works( make_client, client_flush_method, use_pickle, - compressionlevel, + compression_level, + compression_algo, + http2, maybe_monkeypatched_threading, ): caplog.set_level(logging.DEBUG) + + experiments = {} + if compression_level is not None: + experiments["transport_compression_level"] = compression_level + + if compression_algo is not None: + experiments["transport_compression_algo"] = compression_algo + + if http2: + experiments["transport_http2"] = True + client = make_client( debug=debug, - _experiments={ - "transport_zlib_compression_level": compressionlevel, - }, + _experiments=experiments, ) if use_pickle: @@ -152,7 +185,21 @@ def test_transport_works( out, err = capsys.readouterr() assert not err and not out assert capturing_server.captured - assert capturing_server.captured[0].compressed == (compressionlevel > 0) + should_compress = ( + # default is to compress with brotli if available, gzip otherwise + (compression_level is None) + or ( + # setting compression level to 0 means don't compress + compression_level + > 0 + ) + ) and ( + # if we couldn't resolve to a known algo, we don't compress + compression_algo + != "" + ) + + assert capturing_server.captured[0].compressed == should_compress assert any("Sending envelope" in record.msg for record in caplog.records) == debug @@ -172,20 +219,33 @@ def test_transport_num_pools(make_client, num_pools, expected_num_pools): client = make_client(_experiments=_experiments) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert options["num_pools"] == expected_num_pools -def test_two_way_ssl_authentication(make_client): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_two_way_ssl_authentication(make_client, http2): _experiments = {} + if http2: + _experiments["transport_http2"] = True - client = make_client(_experiments=_experiments) - - options = client.transport._get_pool_options( - [], "/path/to/cert.pem", "/path/to/key.pem" + current_dir = os.path.dirname(__file__) + cert_file = f"{current_dir}/test.pem" + key_file = f"{current_dir}/test.key" + client = make_client( + cert_file=cert_file, + key_file=key_file, + _experiments=_experiments, ) - assert options["cert_file"] == "/path/to/cert.pem" - assert options["key_file"] == "/path/to/key.pem" + options = client.transport._get_pool_options() + + if http2: + assert options["ssl_context"] is not None + else: + assert options["cert_file"] == cert_file + assert options["key_file"] == key_file def test_socket_options(make_client): @@ -197,23 +257,39 @@ def test_socket_options(make_client): client = make_client(socket_options=socket_options) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert options["socket_options"] == socket_options def test_keep_alive_true(make_client): client = make_client(keep_alive=True) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert options["socket_options"] == KEEP_ALIVE_SOCKET_OPTIONS -def test_keep_alive_off_by_default(make_client): +def test_keep_alive_on_by_default(make_client): client = make_client() - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert "socket_options" not in options +@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") +def test_http2_with_https_dsn(make_client): + client = make_client(_experiments={"transport_http2": True}) + client.transport.parsed_dsn.scheme = "https" + options = client.transport._get_pool_options() + assert options["http2"] is True + + +@pytest.mark.skipif(not PY38, reason="HTTP2 libraries are only available in py3.8+") +def test_no_http2_with_http_dsn(make_client): + client = make_client(_experiments={"transport_http2": True}) + client.transport.parsed_dsn.scheme = "http" + options = client.transport._get_pool_options() + assert options["http2"] is False + + def test_socket_options_override_keep_alive(make_client): socket_options = [ (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), @@ -223,7 +299,7 @@ def test_socket_options_override_keep_alive(make_client): client = make_client(socket_options=socket_options, keep_alive=False) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert options["socket_options"] == socket_options @@ -235,7 +311,7 @@ def test_socket_options_merge_with_keep_alive(make_client): client = make_client(socket_options=socket_options, keep_alive=True) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() try: assert options["socket_options"] == [ (socket.SOL_SOCKET, socket.SO_KEEPALIVE, 42), @@ -257,7 +333,7 @@ def test_socket_options_override_defaults(make_client): # socket option defaults, so we need to set this and not ignore it. client = make_client(socket_options=[]) - options = client.transport._get_pool_options([]) + options = client.transport._get_pool_options() assert options["socket_options"] == [] diff --git a/tests/test_utils.py b/tests/test_utils.py index 40a3296564..6e01bb4f3a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ import threading import re import sys -from datetime import timedelta +from datetime import timedelta, datetime, timezone from unittest import mock import pytest @@ -12,6 +12,9 @@ from sentry_sdk.utils import ( Components, Dsn, + datetime_from_isoformat, + env_to_bool, + format_timestamp, get_current_thread_meta, get_default_release, get_error_message, @@ -28,14 +31,12 @@ _get_installed_modules, _generate_installed_modules, ensure_integration_enabled, - ensure_integration_enabled_async, ) class TestIntegration(Integration): """ - Test integration for testing ensure_integration_enabled and - ensure_integration_enabled_async decorators. + Test integration for testing ensure_integration_enabled decorator. """ identifier = "test" @@ -59,6 +60,126 @@ def _normalize_distribution_name(name): return re.sub(r"[-_.]+", "-", name).lower() +@pytest.mark.parametrize( + ("input_str", "expected_output"), + ( + ( + "2021-01-01T00:00:00.000000Z", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), # UTC time + ( + "2021-01-01T00:00:00.000000", + datetime(2021, 1, 1).astimezone(timezone.utc), + ), # No TZ -- assume local but convert to UTC + ( + "2021-01-01T00:00:00Z", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), # UTC - No milliseconds + ( + "2021-01-01T00:00:00.000000+00:00", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000-00:00", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000+0000", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000-0000", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2020-12-31T00:00:00.000000+02:00", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=2))), + ), # UTC+2 time + ( + "2020-12-31T00:00:00.000000-0200", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), + ), # UTC-2 time + ( + "2020-12-31T00:00:00-0200", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), + ), # UTC-2 time - no milliseconds + ), +) +def test_datetime_from_isoformat(input_str, expected_output): + assert datetime_from_isoformat(input_str) == expected_output, input_str + + +@pytest.mark.parametrize( + "env_var_value,strict,expected", + [ + (None, True, None), + (None, False, False), + ("", True, None), + ("", False, False), + ("t", True, True), + ("T", True, True), + ("t", False, True), + ("T", False, True), + ("y", True, True), + ("Y", True, True), + ("y", False, True), + ("Y", False, True), + ("1", True, True), + ("1", False, True), + ("True", True, True), + ("True", False, True), + ("true", True, True), + ("true", False, True), + ("tRuE", True, True), + ("tRuE", False, True), + ("Yes", True, True), + ("Yes", False, True), + ("yes", True, True), + ("yes", False, True), + ("yEs", True, True), + ("yEs", False, True), + ("On", True, True), + ("On", False, True), + ("on", True, True), + ("on", False, True), + ("oN", True, True), + ("oN", False, True), + ("f", True, False), + ("f", False, False), + ("n", True, False), + ("N", True, False), + ("n", False, False), + ("N", False, False), + ("0", True, False), + ("0", False, False), + ("False", True, False), + ("False", False, False), + ("false", True, False), + ("false", False, False), + ("FaLsE", True, False), + ("FaLsE", False, False), + ("No", True, False), + ("No", False, False), + ("no", True, False), + ("no", False, False), + ("nO", True, False), + ("nO", False, False), + ("Off", True, False), + ("Off", False, False), + ("off", True, False), + ("off", False, False), + ("oFf", True, False), + ("oFf", False, False), + ("xxx", True, None), + ("xxx", False, True), + ], +) +def test_env_to_bool(env_var_value, strict, expected): + assert ( + env_to_bool(env_var_value, strict=strict) == expected + ), f"Value: {env_var_value}, strict: {strict}" + + @pytest.mark.parametrize( ("url", "expected_result"), [ @@ -660,90 +781,6 @@ def function_to_patch(): assert patched_function.__name__ == "function_to_patch" -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_enabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[TestIntegration()]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "patched" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_integration_disabled(sentry_init): - # Setup variables and functions for the test - async def original_function(): - return "original" - - async def function_to_patch(): - return "patched" - - sentry_init(integrations=[]) # TestIntegration is disabled - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async( - TestIntegration, original_function - )(function_to_patch) - - assert await patched_function() == "original" - assert patched_function.__name__ == "original_function" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_enabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[TestIntegration]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "patched" - assert patched_function.__name__ == "function_to_patch" - - -@pytest.mark.asyncio -async def test_ensure_integration_enabled_async_no_original_function_disabled( - sentry_init, -): - shared_variable = "original" - - async def function_to_patch(): - nonlocal shared_variable - shared_variable = "patched" - - sentry_init(integrations=[]) - - # Test the decorator by applying to function_to_patch - patched_function = ensure_integration_enabled_async(TestIntegration)( - function_to_patch - ) - await patched_function() - - assert shared_variable == "original" - assert patched_function.__name__ == "function_to_patch" - - @pytest.mark.parametrize( "delta,expected_milliseconds", [ @@ -878,3 +915,39 @@ def target(): thread.start() thread.join() assert (main_thread.ident, main_thread.name) == results.get(timeout=1) + + +@pytest.mark.parametrize( + ("datetime_object", "expected_output"), + ( + ( + datetime(2021, 1, 1, tzinfo=timezone.utc), + "2021-01-01T00:00:00.000000Z", + ), # UTC time + ( + datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=2))), + "2020-12-31T22:00:00.000000Z", + ), # UTC+2 time + ( + datetime(2021, 1, 1, tzinfo=timezone(timedelta(hours=-7))), + "2021-01-01T07:00:00.000000Z", + ), # UTC-7 time + ( + datetime(2021, 2, 3, 4, 56, 7, 890123, tzinfo=timezone.utc), + "2021-02-03T04:56:07.890123Z", + ), # UTC time all non-zero fields + ), +) +def test_format_timestamp(datetime_object, expected_output): + formatted = format_timestamp(datetime_object) + + assert formatted == expected_output + + +def test_format_timestamp_naive(): + datetime_object = datetime(2021, 1, 1) + timestamp_regex = r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{6}Z" + + # Ensure that some timestamp is returned, without error. We currently treat these as local time, but this is an + # implementation detail which we should not assert here. + assert re.fullmatch(timestamp_regex, format_timestamp(datetime_object)) diff --git a/tests/tracing/test_decorator.py b/tests/tracing/test_decorator.py index 584268fbdd..18a66bd43e 100644 --- a/tests/tracing/test_decorator.py +++ b/tests/tracing/test_decorator.py @@ -26,7 +26,7 @@ def test_trace_decorator(): result2 = start_child_span_decorator(my_example_function)() fake_start_child.assert_called_once_with( - op="function", description="test_decorator.my_example_function" + op="function", name="test_decorator.my_example_function" ) assert result2 == "return_of_sync_function" @@ -58,7 +58,7 @@ async def test_trace_decorator_async(): result2 = await start_child_span_decorator(my_async_example_function)() fake_start_child.assert_called_once_with( op="function", - description="test_decorator.my_async_example_function", + name="test_decorator.my_async_example_function", ) assert result2 == "return_of_async_function" diff --git a/tests/tracing/test_integration_tests.py b/tests/tracing/test_integration_tests.py index 47170af97b..e27dbea901 100644 --- a/tests/tracing/test_integration_tests.py +++ b/tests/tracing/test_integration_tests.py @@ -23,10 +23,10 @@ def test_basic(sentry_init, capture_events, sample_rate): with start_transaction(name="hi") as transaction: transaction.set_status(SPANSTATUS.OK) with pytest.raises(ZeroDivisionError): - with start_span(op="foo", description="foodesc"): + with start_span(op="foo", name="foodesc"): 1 / 0 - with start_span(op="bar", description="bardesc"): + with start_span(op="bar", name="bardesc"): pass if sample_rate: @@ -158,7 +158,7 @@ def test_dynamic_sampling_head_sdk_creates_dsc( assert baggage.third_party_items == "" with start_transaction(transaction): - with start_span(op="foo", description="foodesc"): + with start_span(op="foo", name="foodesc"): pass # finish will create a new baggage entry @@ -211,7 +211,7 @@ def test_memory_usage(sentry_init, capture_events, args, expected_refcount): with start_transaction(name="hi"): for i in range(100): - with start_span(op="helloworld", description="hi {}".format(i)) as span: + with start_span(op="helloworld", name="hi {}".format(i)) as span: def foo(): pass @@ -248,14 +248,14 @@ def capture_envelope(self, envelope): pass def capture_event(self, event): - start_span(op="toolate", description="justdont") + start_span(op="toolate", name="justdont") pass sentry_init(traces_sample_rate=1, transport=CustomTransport()) events = capture_events() with start_transaction(name="hi"): - with start_span(op="bar", description="bardesc"): + with start_span(op="bar", name="bardesc"): pass assert len(events) == 1 @@ -269,7 +269,7 @@ def test_trace_propagation_meta_head_sdk(sentry_init): span = None with start_transaction(transaction): - with start_span(op="foo", description="foodesc") as current_span: + with start_span(op="foo", name="foodesc") as current_span: span = current_span meta = sentry_sdk.get_current_scope().trace_propagation_meta() diff --git a/tests/tracing/test_misc.py b/tests/tracing/test_misc.py index 02966642fd..de2f782538 100644 --- a/tests/tracing/test_misc.py +++ b/tests/tracing/test_misc.py @@ -36,11 +36,6 @@ def test_transaction_naming(sentry_init, capture_events): sentry_init(traces_sample_rate=1.0) events = capture_events() - # only transactions have names - spans don't - with pytest.raises(TypeError): - start_span(name="foo") - assert len(events) == 0 - # default name in event if no name is passed with start_transaction() as transaction: pass diff --git a/tests/tracing/test_noop_span.py b/tests/tracing/test_noop_span.py index ec2c7782f3..36778cd485 100644 --- a/tests/tracing/test_noop_span.py +++ b/tests/tracing/test_noop_span.py @@ -23,7 +23,7 @@ def test_noop_start_transaction(sentry_init): def test_noop_start_span(sentry_init): sentry_init(instrumenter="otel") - with sentry_sdk.start_span(op="http", description="GET /") as span: + with sentry_sdk.start_span(op="http", name="GET /") as span: assert isinstance(span, NoOpSpan) assert sentry_sdk.get_current_scope().span is span diff --git a/tests/tracing/test_span_name.py b/tests/tracing/test_span_name.py new file mode 100644 index 0000000000..9c1768990a --- /dev/null +++ b/tests/tracing/test_span_name.py @@ -0,0 +1,59 @@ +import pytest + +import sentry_sdk + + +def test_start_span_description(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + with sentry_sdk.start_transaction(name="hi"): + with pytest.deprecated_call(): + with sentry_sdk.start_span(op="foo", description="span-desc"): + ... + + (event,) = events + + assert event["spans"][0]["description"] == "span-desc" + + +def test_start_span_name(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + with sentry_sdk.start_transaction(name="hi"): + with sentry_sdk.start_span(op="foo", name="span-name"): + ... + + (event,) = events + + assert event["spans"][0]["description"] == "span-name" + + +def test_start_child_description(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + with sentry_sdk.start_transaction(name="hi"): + with pytest.deprecated_call(): + with sentry_sdk.start_span(op="foo", description="span-desc") as span: + with span.start_child(op="bar", description="child-desc"): + ... + + (event,) = events + + assert event["spans"][-1]["description"] == "child-desc" + + +def test_start_child_name(sentry_init, capture_events): + sentry_init(traces_sample_rate=1.0) + events = capture_events() + + with sentry_sdk.start_transaction(name="hi"): + with sentry_sdk.start_span(op="foo", name="span-name") as span: + with span.start_child(op="bar", name="child-name"): + ... + + (event,) = events + + assert event["spans"][-1]["description"] == "child-name" diff --git a/tests/tracing/test_span_origin.py b/tests/tracing/test_span_origin.py index f880279f08..16635871b3 100644 --- a/tests/tracing/test_span_origin.py +++ b/tests/tracing/test_span_origin.py @@ -6,7 +6,7 @@ def test_span_origin_manual(sentry_init, capture_events): events = capture_events() with start_transaction(name="hi"): - with start_span(op="foo", description="bar"): + with start_span(op="foo", name="bar"): pass (event,) = events @@ -21,11 +21,11 @@ def test_span_origin_custom(sentry_init, capture_events): events = capture_events() with start_transaction(name="hi"): - with start_span(op="foo", description="bar", origin="foo.foo2.foo3"): + with start_span(op="foo", name="bar", origin="foo.foo2.foo3"): pass with start_transaction(name="ho", origin="ho.ho2.ho3"): - with start_span(op="baz", description="qux", origin="baz.baz2.baz3"): + with start_span(op="baz", name="qux", origin="baz.baz2.baz3"): pass (first_transaction, second_transaction) = events diff --git a/tox.ini b/tox.ini index 3acf70bb6f..717ea62141 100644 --- a/tox.ini +++ b/tox.ini @@ -30,22 +30,22 @@ envlist = # AIOHTTP {py3.7}-aiohttp-v{3.4} {py3.7,py3.9,py3.11}-aiohttp-v{3.8} - {py3.8,py3.11,py3.12}-aiohttp-latest + {py3.8,py3.12,py3.13}-aiohttp-latest # Anthropic - {py3.7,py3.11,py3.12}-anthropic-v{0.16,0.25} + {py3.8,py3.11,py3.12}-anthropic-v{0.16,0.28,0.40} {py3.7,py3.11,py3.12}-anthropic-latest # Ariadne {py3.8,py3.11}-ariadne-v{0.20} - {py3.8,py3.11,py3.12}-ariadne-latest + {py3.8,py3.12,py3.13}-ariadne-latest # Arq {py3.7,py3.11}-arq-v{0.23} - {py3.7,py3.11,py3.12}-arq-latest + {py3.7,py3.12,py3.13}-arq-latest # Asgi - {py3.7,py3.11,py3.12}-asgi + {py3.7,py3.12,py3.13}-asgi # asyncpg {py3.7,py3.10}-asyncpg-v{0.23} @@ -65,29 +65,29 @@ envlist = {py3.6,py3.7}-boto3-v{1.12} {py3.7,py3.11,py3.12}-boto3-v{1.23} {py3.11,py3.12}-boto3-v{1.34} - {py3.11,py3.12}-boto3-latest + {py3.11,py3.12,py3.13}-boto3-latest # Bottle {py3.6,py3.9}-bottle-v{0.12} - {py3.6,py3.11,py3.12}-bottle-latest + {py3.6,py3.12,py3.13}-bottle-latest # Celery {py3.6,py3.8}-celery-v{4} {py3.6,py3.8}-celery-v{5.0} {py3.7,py3.10}-celery-v{5.1,5.2} - {py3.8,py3.11,py3.12}-celery-v{5.3,5.4} - {py3.8,py3.11,py3.12}-celery-latest + {py3.8,py3.11,py3.12}-celery-v{5.3,5.4,5.5} + {py3.8,py3.12,py3.13}-celery-latest # Chalice {py3.6,py3.9}-chalice-v{1.16} - {py3.8,py3.12}-chalice-latest + {py3.8,py3.12,py3.13}-chalice-latest # Clickhouse Driver {py3.8,py3.11}-clickhouse_driver-v{0.2.0} - {py3.8,py3.11,py3.12}-clickhouse_driver-latest + {py3.8,py3.12,py3.13}-clickhouse_driver-latest # Cloud Resource Context - {py3.6,py3.11,py3.12}-cloud_resource_context + {py3.6,py3.12,py3.13}-cloud_resource_context # Cohere {py3.9,py3.11,py3.12}-cohere-v5 @@ -106,33 +106,40 @@ envlist = {py3.8,py3.11,py3.12}-django-v{4.0,4.1,4.2} # - Django 5.x {py3.10,py3.11,py3.12}-django-v{5.0,5.1} - {py3.10,py3.11,py3.12}-django-latest + {py3.10,py3.12,py3.13}-django-latest + + # dramatiq + {py3.6,py3.9}-dramatiq-v{1.13} + {py3.7,py3.10,py3.11}-dramatiq-v{1.15} + {py3.8,py3.11,py3.12}-dramatiq-v{1.17} + {py3.8,py3.11,py3.12}-dramatiq-latest # Falcon {py3.6,py3.7}-falcon-v{1,1.4,2} {py3.6,py3.11,py3.12}-falcon-v{3} + {py3.8,py3.11,py3.12}-falcon-v{4} {py3.7,py3.11,py3.12}-falcon-latest # FastAPI {py3.7,py3.10}-fastapi-v{0.79} - {py3.8,py3.11,py3.12}-fastapi-latest + {py3.8,py3.12,py3.13}-fastapi-latest # Flask {py3.6,py3.8}-flask-v{1} {py3.8,py3.11,py3.12}-flask-v{2} {py3.10,py3.11,py3.12}-flask-v{3} - {py3.10,py3.11,py3.12}-flask-latest + {py3.10,py3.12,py3.13}-flask-latest # GCP {py3.7}-gcp # GQL {py3.7,py3.11}-gql-v{3.4} - {py3.7,py3.11,py3.12}-gql-latest + {py3.7,py3.12,py3.13}-gql-latest # Graphene {py3.7,py3.11}-graphene-v{3.3} - {py3.7,py3.11,py3.12}-graphene-latest + {py3.7,py3.12,py3.13}-graphene-latest # gRPC {py3.7,py3.9}-grpc-v{1.39} @@ -145,53 +152,62 @@ envlist = {py3.6,py3.10}-httpx-v{0.20,0.22} {py3.7,py3.11,py3.12}-httpx-v{0.23,0.24} {py3.9,py3.11,py3.12}-httpx-v{0.25,0.27} - {py3.9,py3.11,py3.12}-httpx-latest + {py3.9,py3.12,py3.13}-httpx-latest # Huey {py3.6,py3.11,py3.12}-huey-v{2.0} - {py3.6,py3.11,py3.12}-huey-latest + {py3.6,py3.12,py3.13}-huey-latest # Huggingface Hub - {py3.9,py3.11,py3.12}-huggingface_hub-{v0.22,latest} + {py3.9,py3.12,py3.13}-huggingface_hub-{v0.22} + {py3.9,py3.12,py3.13}-huggingface_hub-latest # Langchain {py3.9,py3.11,py3.12}-langchain-v0.1 + {py3.9,py3.11,py3.12}-langchain-v0.3 {py3.9,py3.11,py3.12}-langchain-latest {py3.9,py3.11,py3.12}-langchain-notiktoken # Litestar - # litestar 2.0.0 is the earliest version that supports Python < 3.12 {py3.8,py3.11}-litestar-v{2.0} - # litestar 2.3.0 is the earliest version that supports Python 3.12 - {py3.12}-litestar-v{2.3} - {py3.8,py3.11,py3.12}-litestar-v{2.5} + {py3.8,py3.11,py3.12}-litestar-v{2.6} + {py3.8,py3.11,py3.12}-litestar-v{2.12} {py3.8,py3.11,py3.12}-litestar-latest # Loguru {py3.6,py3.11,py3.12}-loguru-v{0.5} - {py3.6,py3.11,py3.12}-loguru-latest + {py3.6,py3.12,py3.13}-loguru-latest # OpenAI - {py3.9,py3.11,py3.12}-openai-v1 + {py3.9,py3.11,py3.12}-openai-v1.0 + {py3.9,py3.11,py3.12}-openai-v1.22 + {py3.9,py3.11,py3.12}-openai-v1.55 {py3.9,py3.11,py3.12}-openai-latest {py3.9,py3.11,py3.12}-openai-notiktoken + # OpenFeature + {py3.8,py3.12,py3.13}-openfeature-v0.7 + {py3.8,py3.12,py3.13}-openfeature-latest + + # LaunchDarkly + {py3.8,py3.12,py3.13}-launchdarkly-v9.8.0 + {py3.8,py3.12,py3.13}-launchdarkly-latest + # OpenTelemetry (OTel) - {py3.7,py3.9,py3.11,py3.12}-opentelemetry + {py3.7,py3.9,py3.12,py3.13}-opentelemetry # OpenTelemetry Experimental (POTel) - # XXX add 3.12 when officially supported - {py3.8,py3.9,py3.10,py3.11}-potel + {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-potel # pure_eval - {py3.6,py3.11,py3.12}-pure_eval + {py3.6,py3.12,py3.13}-pure_eval # PyMongo (Mongo DB) {py3.6}-pymongo-v{3.1} {py3.6,py3.9}-pymongo-v{3.12} {py3.6,py3.11}-pymongo-v{4.0} {py3.7,py3.11,py3.12}-pymongo-v{4.3,4.7} - {py3.7,py3.11,py3.12}-pymongo-latest + {py3.7,py3.12,py3.13}-pymongo-latest # Pyramid {py3.6,py3.11}-pyramid-v{1.6} @@ -202,34 +218,38 @@ envlist = # Quart {py3.7,py3.11}-quart-v{0.16} {py3.8,py3.11,py3.12}-quart-v{0.19} - {py3.8,py3.11,py3.12}-quart-latest + {py3.8,py3.12,py3.13}-quart-latest + + # Ray + {py3.10,py3.11}-ray-v{2.34} + {py3.10,py3.11}-ray-latest # Redis {py3.6,py3.8}-redis-v{3} {py3.7,py3.8,py3.11}-redis-v{4} {py3.7,py3.11,py3.12}-redis-v{5} - {py3.7,py3.11,py3.12}-redis-latest + {py3.7,py3.12,py3.13}-redis-latest # Redis Cluster {py3.6,py3.8}-redis_py_cluster_legacy-v{1,2} # no -latest, not developed anymore # Requests - {py3.6,py3.8,py3.11,py3.12}-requests + {py3.6,py3.8,py3.12,py3.13}-requests # RQ (Redis Queue) {py3.6}-rq-v{0.6} {py3.6,py3.9}-rq-v{0.13,1.0} {py3.6,py3.11}-rq-v{1.5,1.10} {py3.7,py3.11,py3.12}-rq-v{1.15,1.16} - {py3.7,py3.11,py3.12}-rq-latest + {py3.7,py3.12,py3.13}-rq-latest # Sanic {py3.6,py3.7}-sanic-v{0.8} {py3.6,py3.8}-sanic-v{20} {py3.7,py3.11}-sanic-v{22} {py3.7,py3.11}-sanic-v{23} - {py3.8,py3.11}-sanic-latest + {py3.8,py3.11,py3.12}-sanic-latest # Spark {py3.8,py3.10,py3.11}-spark-v{3.1,3.3,3.5} @@ -237,9 +257,9 @@ envlist = # Starlette {py3.7,py3.10}-starlette-v{0.19} - {py3.7,py3.11}-starlette-v{0.20,0.24,0.28} - {py3.8,py3.11,py3.12}-starlette-v{0.32,0.36} - {py3.8,py3.11,py3.12}-starlette-latest + {py3.7,py3.11}-starlette-v{0.24,0.28} + {py3.8,py3.11,py3.12}-starlette-v{0.32,0.36,0.40} + {py3.8,py3.12,py3.13}-starlette-latest # Starlite {py3.8,py3.11}-starlite-v{1.48,1.51} @@ -248,12 +268,12 @@ envlist = # SQL Alchemy {py3.6,py3.9}-sqlalchemy-v{1.2,1.4} {py3.7,py3.11}-sqlalchemy-v{2.0} - {py3.7,py3.11,py3.12}-sqlalchemy-latest + {py3.7,py3.12,py3.13}-sqlalchemy-latest # Strawberry {py3.8,py3.11}-strawberry-v{0.209} {py3.8,py3.11,py3.12}-strawberry-v{0.222} - {py3.8,py3.11,py3.12}-strawberry-latest + {py3.8,py3.12,py3.13}-strawberry-latest # Tornado {py3.8,py3.11,py3.12}-tornado-v{6.0} @@ -265,7 +285,11 @@ envlist = {py3.6,py3.8}-trytond-v{5} {py3.6,py3.11}-trytond-v{6} {py3.8,py3.11,py3.12}-trytond-v{7} - {py3.8,py3.11,py3.12}-trytond-latest + {py3.8,py3.12,py3.13}-trytond-latest + + # Typer + {py3.7,py3.12,py3.13}-typer-v{0.15} + {py3.7,py3.12,py3.13}-typer-latest [testenv] deps = @@ -279,12 +303,12 @@ deps = # === Common === py3.8-common: hypothesis - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common: pytest-asyncio + common: pytest-asyncio # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-common: pytest<7.0.0 - py3.13-common: pytest + {py3.6,py3.7}-common: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common: pytest # === Gevent === {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-gevent: gevent>=22.10.0, <22.11.0 @@ -292,7 +316,8 @@ deps = # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest<7.0.0 + {py3.6,py3.7}-gevent: pytest<7.0.0 + {py3.8,py3.9,py3.10,py3.11,py3.12}-gevent: pytest # === Integrations === @@ -305,8 +330,11 @@ deps = aiohttp-latest: pytest-asyncio # Anthropic - anthropic-v0.25: anthropic~=0.25.0 + anthropic: pytest-asyncio + anthropic-v{0.16,0.28}: httpx<0.28.0 anthropic-v0.16: anthropic~=0.16.0 + anthropic-v0.28: anthropic~=0.28.0 + anthropic-v0.40: anthropic~=0.40.0 anthropic-latest: anthropic # Ariadne @@ -359,17 +387,17 @@ deps = celery-v5.2: Celery~=5.2.0 celery-v5.3: Celery~=5.3.0 celery-v5.4: Celery~=5.4.0 + # TODO: update when stable is out + celery-v5.5: Celery==5.5.0rc3 celery-latest: Celery + celery: newrelic {py3.7}-celery: importlib-metadata<5.0 - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12}-celery: newrelic # Chalice + chalice: pytest-chalice==0.0.5 chalice-v1.16: chalice~=1.16.0 chalice-latest: chalice - chalice: pytest-chalice==0.0.5 - - {py3.7,py3.8}-chalice: botocore~=1.31 # Clickhouse Driver clickhouse_driver-v0.2.0: clickhouse_driver~=0.2.0 @@ -383,6 +411,7 @@ deps = django: psycopg2-binary django-v{1.11,2.0,2.1,2.2,3.0,3.1,3.2}: djangorestframework>=3.0.0,<4.0.0 django-v{2.0,2.2,3.0,3.2,4.0,4.1,4.2,5.0,5.1}: channels[daphne] + django-v{2.2,3.0}: six django-v{1.11,2.0,2.2,3.0,3.2}: Werkzeug<2.1.0 django-v{1.11,2.0,2.2,3.0}: pytest-django<4.0 django-v{3.2,4.0,4.1,4.2,5.0,5.1}: pytest-django @@ -407,11 +436,18 @@ deps = django-v5.1: Django==5.1rc1 django-latest: Django + # dramatiq + dramatiq-v1.13: dramatiq>=1.13,<1.14 + dramatiq-v1.15: dramatiq>=1.15,<1.16 + dramatiq-v1.17: dramatiq>=1.17,<1.18 + dramatiq-latest: dramatiq + # Falcon falcon-v1.4: falcon~=1.4.0 falcon-v1: falcon~=1.0 falcon-v2: falcon~=2.0 falcon-v3: falcon~=3.0 + falcon-v4: falcon~=4.0 falcon-latest: falcon # FastAPI @@ -489,22 +525,25 @@ deps = langchain-v0.1: openai~=1.0.0 langchain-v0.1: langchain~=0.1.11 langchain-v0.1: tiktoken~=0.6.0 - langchain-latest: langchain - langchain-latest: langchain-openai - langchain-latest: openai>=1.6.1 + langchain-v0.1: httpx<0.28.0 + langchain-v0.3: langchain~=0.3.0 + langchain-v0.3: langchain-community + langchain-v0.3: tiktoken + langchain-v0.3: openai + langchain-{latest,notiktoken}: langchain + langchain-{latest,notiktoken}: langchain-openai + langchain-{latest,notiktoken}: openai>=1.6.1 langchain-latest: tiktoken~=0.6.0 - langchain-notiktoken: langchain - langchain-notiktoken: langchain-openai - langchain-notiktoken: openai>=1.6.1 # Litestar litestar: pytest-asyncio litestar: python-multipart litestar: requests litestar: cryptography + litestar-v{2.0,2.6}: httpx<0.28 litestar-v2.0: litestar~=2.0.0 - litestar-v2.3: litestar~=2.3.0 - litestar-v2.5: litestar~=2.5.0 + litestar-v2.6: litestar~=2.6.0 + litestar-v2.12: litestar~=2.12.0 litestar-latest: litestar # Loguru @@ -512,12 +551,27 @@ deps = loguru-latest: loguru # OpenAI - openai-v1: openai~=1.0.0 - openai-v1: tiktoken~=0.6.0 + openai: pytest-asyncio + openai-v1.0: openai~=1.0.0 + openai-v1.0: tiktoken + openai-v1.0: httpx<0.28.0 + openai-v1.22: openai~=1.22.0 + openai-v1.22: tiktoken + openai-v1.22: httpx<0.28.0 + openai-v1.55: openai~=1.55.0 + openai-v1.55: tiktoken openai-latest: openai openai-latest: tiktoken~=0.6.0 openai-notiktoken: openai + # OpenFeature + openfeature-v0.7: openfeature-sdk~=0.7.1 + openfeature-latest: openfeature-sdk + + # LaunchDarkly + launchdarkly-v9.8.0: launchdarkly-server-sdk~=9.8.0 + launchdarkly-latest: launchdarkly-server-sdk + # OpenTelemetry (OTel) opentelemetry: opentelemetry-distro @@ -553,12 +607,18 @@ deps = quart-v0.16: quart~=0.16.0 quart-v0.19: Werkzeug>=3.0.0 quart-v0.19: quart~=0.19.0 + {py3.8}-quart: taskgroup==0.0.0a4 quart-latest: quart + # Ray + ray-v2.34: ray~=2.34.0 + ray-latest: ray + # Redis redis: fakeredis!=1.7.4 redis: pytest<8.0.0 - {py3.7,py3.8,py3.9,py3.10,py3.11}-redis: pytest-asyncio + {py3.6,py3.7}-redis: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 + {py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-redis: pytest-asyncio redis-v3: redis~=3.0 redis-v4: redis~=4.0 redis-v5: redis~=5.0 @@ -577,7 +637,9 @@ deps = rq-v{0.6}: redis<3.2.2 rq-v{0.13,1.0,1.5,1.10}: fakeredis>=1.0,<1.7.4 rq-v{1.15,1.16}: fakeredis + {py3.6,py3.7}-rq-v{1.15,1.16}: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 rq-latest: fakeredis + {py3.6,py3.7}-rq-latest: fakeredis!=2.26.0 # https://github.com/cunla/fakeredis-py/issues/341 rq-v0.6: rq~=0.6.0 rq-v0.13: rq~=0.13.0 rq-v1.0: rq~=1.0.0 @@ -603,22 +665,26 @@ deps = spark-v3.1: pyspark~=3.1.0 spark-v3.3: pyspark~=3.3.0 spark-v3.5: pyspark~=3.5.0 + # TODO: update to ~=4.0.0 once stable is out + spark-v4.0: pyspark==4.0.0.dev2 spark-latest: pyspark # Starlette starlette: pytest-asyncio starlette: python-multipart starlette: requests - starlette: httpx # (this is a dependency of httpx) starlette: anyio<4.0.0 starlette: jinja2 + starlette-v{0.19,0.24,0.28,0.32,0.36}: httpx<0.28.0 + starlette-v0.40: httpx + starlette-latest: httpx starlette-v0.19: starlette~=0.19.0 - starlette-v0.20: starlette~=0.20.0 starlette-v0.24: starlette~=0.24.0 starlette-v0.28: starlette~=0.28.0 starlette-v0.32: starlette~=0.32.0 starlette-v0.36: starlette~=0.36.0 + starlette-v0.40: starlette~=0.40.0 starlette-latest: starlette # Starlite @@ -627,6 +693,7 @@ deps = starlite: requests starlite: cryptography starlite: pydantic<2.0.0 + starlite: httpx<0.28 starlite-v{1.48}: starlite~=1.48.0 starlite-v{1.51}: starlite~=1.51.0 @@ -645,7 +712,9 @@ deps = strawberry-latest: strawberry-graphql[fastapi,flask] # Tornado - tornado: pytest<8.2 + # Tornado <6.4.1 is incompatible with Pytest ≥8.2 + # See https://github.com/tornadoweb/tornado/pull/3382. + tornado-{v6.0,v6.2}: pytest<8.2 tornado-v6.0: tornado~=6.0.0 tornado-v6.2: tornado~=6.2.0 tornado-latest: tornado @@ -659,10 +728,16 @@ deps = trytond-v7: trytond~=7.0 trytond-latest: trytond + # Typer + typer-v0.15: typer~=0.15.0 + typer-latest: typer + setenv = PYTHONDONTWRITEBYTECODE=1 OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES - COVERAGE_FILE=.coverage-{envname} + COVERAGE_FILE=.coverage-sentry-{envname} + py3.6: COVERAGE_RCFILE=.coveragerc36 + django: DJANGO_SETTINGS_MODULE=tests.integrations.django.myapp.settings common: TESTPATH=tests @@ -683,6 +758,7 @@ setenv = cohere: TESTPATH=tests/integrations/cohere cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context django: TESTPATH=tests/integrations/django + dramatiq: TESTPATH=tests/integrations/dramatiq falcon: TESTPATH=tests/integrations/falcon fastapi: TESTPATH=tests/integrations/fastapi flask: TESTPATH=tests/integrations/flask @@ -694,15 +770,18 @@ setenv = huey: TESTPATH=tests/integrations/huey huggingface_hub: TESTPATH=tests/integrations/huggingface_hub langchain: TESTPATH=tests/integrations/langchain + launchdarkly: TESTPATH=tests/integrations/launchdarkly litestar: TESTPATH=tests/integrations/litestar loguru: TESTPATH=tests/integrations/loguru openai: TESTPATH=tests/integrations/openai + openfeature: TESTPATH=tests/integrations/openfeature opentelemetry: TESTPATH=tests/integrations/opentelemetry potel: TESTPATH=tests/integrations/opentelemetry pure_eval: TESTPATH=tests/integrations/pure_eval pymongo: TESTPATH=tests/integrations/pymongo pyramid: TESTPATH=tests/integrations/pyramid quart: TESTPATH=tests/integrations/quart + ray: TESTPATH=tests/integrations/ray redis: TESTPATH=tests/integrations/redis redis_py_cluster_legacy: TESTPATH=tests/integrations/redis_py_cluster_legacy requests: TESTPATH=tests/integrations/requests @@ -715,6 +794,7 @@ setenv = strawberry: TESTPATH=tests/integrations/strawberry tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond + typer: TESTPATH=tests/integrations/typer socket: TESTPATH=tests/integrations/socket passenv = @@ -741,6 +821,7 @@ basepython = py3.10: python3.10 py3.11: python3.11 py3.12: python3.12 + py3.13: python3.13 # Python version is pinned here because flake8 actually behaves differently # depending on which version is used. You can patch this out to point to