diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f0002fe486..12db62315a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,4 +4,4 @@ Thank you for contributing to `sentry-python`! Please add tests to validate your changes, and lint your code using `tox -e linters`. -Running the test suite on your PR might require maintainer approval. The AWS Lambda tests additionally require a maintainer to add a special label, and they will fail until this label is added. +Running the test suite on your PR might require maintainer approval. \ No newline at end of file diff --git a/.github/workflows/scripts/trigger_tests_on_label.py b/.github/workflows/scripts/trigger_tests_on_label.py deleted file mode 100644 index f6039fd16a..0000000000 --- a/.github/workflows/scripts/trigger_tests_on_label.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -import argparse -import json -import os -from urllib.parse import quote -from urllib.request import Request, urlopen - -LABEL = "Trigger: tests using secrets" - - -def _has_write(repo_id: int, username: str, *, token: str) -> bool: - req = Request( - f"https://api.github.com/repositories/{repo_id}/collaborators/{username}/permission", - headers={"Authorization": f"token {token}"}, - ) - contents = json.load(urlopen(req, timeout=10)) - - return contents["permission"] in {"admin", "write"} - - -def _remove_label(repo_id: int, pr: int, label: str, *, token: str) -> None: - quoted_label = quote(label) - req = Request( - f"https://api.github.com/repositories/{repo_id}/issues/{pr}/labels/{quoted_label}", - method="DELETE", - headers={"Authorization": f"token {token}"}, - ) - urlopen(req) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--repo-id", type=int, required=True) - parser.add_argument("--pr", type=int, required=True) - parser.add_argument("--event", required=True) - parser.add_argument("--username", required=True) - parser.add_argument("--label-names", type=json.loads, required=True) - args = parser.parse_args() - - token = os.environ["GITHUB_TOKEN"] - - write_permission = _has_write(args.repo_id, args.username, token=token) - - if ( - not write_permission - # `reopened` is included here due to close => push => reopen - and args.event in {"synchronize", "reopened"} - and LABEL in args.label_names - ): - print(f"Invalidating label [{LABEL}] due to code change...") - _remove_label(args.repo_id, args.pr, LABEL, token=token) - args.label_names.remove(LABEL) - - if write_permission or LABEL in args.label_names: - print("Permissions passed!") - print(f"- has write permission: {write_permission}") - print(f"- has [{LABEL}] label: {LABEL in args.label_names}") - return 0 - else: - print("Permissions failed!") - print(f"- has write permission: {write_permission}") - print(f"- has [{LABEL}] label: {LABEL in args.label_names}") - print(f"- args.label_names: {args.label_names}") - print( - f"Please have a collaborator add the [{LABEL}] label once they " - f"have reviewed the code to trigger tests." - ) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/.github/workflows/test-integrations-aws.yml b/.github/workflows/test-integrations-aws.yml deleted file mode 100644 index 21171f7843..0000000000 --- a/.github/workflows/test-integrations-aws.yml +++ /dev/null @@ -1,126 +0,0 @@ -# Do not edit this YAML file. This file is generated automatically by executing -# python scripts/split_tox_gh_actions/split_tox_gh_actions.py -# The template responsible for it is in -# scripts/split_tox_gh_actions/templates/base.jinja -name: Test AWS -on: - push: - branches: - - master - - release/** - - potel-base - # XXX: We are using `pull_request_target` instead of `pull_request` because we want - # this to run on forks with access to the secrets necessary to run the test suite. - # Prefer to use `pull_request` when possible. - pull_request_target: - types: [labeled, opened, reopened, synchronize] -# 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 - # `write` is needed to remove the `Trigger: tests using secrets` label - pull-requests: write -env: - SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID: ${{ secrets.SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID }} - SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY: ${{ secrets.SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY }} - BUILD_CACHE_KEY: ${{ github.sha }} - CACHED_BUILD_PATHS: | - ${{ github.workspace }}/dist-serverless -jobs: - check-permissions: - name: permissions check - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v4.2.2 - with: - persist-credentials: false - - name: Check permissions on PR - if: github.event_name == 'pull_request_target' - run: | - python3 -uS .github/workflows/scripts/trigger_tests_on_label.py \ - --repo-id ${{ github.event.repository.id }} \ - --pr ${{ github.event.number }} \ - --event ${{ github.event.action }} \ - --username "$ARG_USERNAME" \ - --label-names "$ARG_LABEL_NAMES" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # these can contain special characters - ARG_USERNAME: ${{ github.event.pull_request.user.login }} - ARG_LABEL_NAMES: ${{ toJSON(github.event.pull_request.labels.*.name) }} - - name: Check permissions on repo branch - if: github.event_name == 'push' - run: true - test-aws-pinned: - name: AWS (pinned) - timeout-minutes: 30 - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - python-version: ["3.9"] - # 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] - needs: check-permissions - steps: - - uses: actions/checkout@v4.2.2 - with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} - - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - allow-prereleases: true - - name: Setup Test Env - run: | - pip install "coverage[toml]" tox - - name: Erase coverage - run: | - coverage erase - - name: Test aws_lambda pinned - 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() && matrix.python-version != '3.6' }} - run: | - coverage combine .coverage-sentry-* - coverage xml - - name: Upload coverage to Codecov - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.3.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 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-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.yml b/.github/workflows/test-integrations-cloud.yml index b929b8d899..efa71c8e0c 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -35,6 +35,10 @@ jobs: # 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] + services: + docker: + image: docker:dind # Required for Docker network management + options: --privileged # Required for Docker-in-Docker operations steps: - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 @@ -47,6 +51,10 @@ jobs: - name: Erase coverage run: | coverage erase + - name: Test aws_lambda latest + run: | + set -x # print commands that are executed + ./scripts/runtox.sh "py${{ matrix.python-version }}-aws_lambda-latest" - name: Test boto3 latest run: | set -x # print commands that are executed @@ -97,12 +105,16 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.6","3.7","3.9","3.11","3.12","3.13"] + 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] + services: + docker: + image: docker:dind # Required for Docker network management + options: --privileged # Required for Docker-in-Docker operations steps: - uses: actions/checkout@v4.2.2 - uses: actions/setup-python@v5 @@ -115,6 +127,10 @@ jobs: - name: Erase coverage run: | coverage erase + - name: Test aws_lambda pinned + run: | + set -x # print commands that are executed + ./scripts/runtox.sh --exclude-latest "py${{ matrix.python-version }}-aws_lambda" - name: Test boto3 pinned run: | set -x # print commands that are executed diff --git a/.gitignore b/.gitignore index 8c7a5f2174..0dad53b2f4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ relay pip-wheel-metadata .mypy_cache .vscode/ + +# for running AWS Lambda tests using AWS SAM +sam.template.yaml diff --git a/requirements-testing.txt b/requirements-testing.txt index dfbd821845..503ab5de68 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -14,3 +14,4 @@ socksio httpcore[http2] setuptools Brotli +docker \ No newline at end of file diff --git a/scripts/aws-cleanup.sh b/scripts/aws-cleanup.sh deleted file mode 100755 index 982835c283..0000000000 --- a/scripts/aws-cleanup.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh -# -# Helper script to clean up AWS Lambda functions created -# by the test suite (tests/integrations/aws_lambda/test_aws.py). -# -# This will delete all Lambda functions named `test_function_*`. -# - -export AWS_DEFAULT_REGION="us-east-1" -export AWS_ACCESS_KEY_ID="$SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID" -export AWS_SECRET_ACCESS_KEY="$SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY" - -for func in $(aws lambda list-functions --output text --query 'Functions[?starts_with(FunctionName, `test_`) == `true`].FunctionName'); do - echo "Deleting $func" - aws lambda delete-function --function-name "$func" -done - -echo "All done! Have a nice day!" diff --git a/scripts/aws-attach-layer-to-lambda-function.sh b/scripts/aws/aws-attach-layer-to-lambda-function.sh similarity index 100% rename from scripts/aws-attach-layer-to-lambda-function.sh rename to scripts/aws/aws-attach-layer-to-lambda-function.sh diff --git a/scripts/aws-delete-lambda-layer-versions.sh b/scripts/aws/aws-delete-lambda-layer-versions.sh similarity index 95% rename from scripts/aws-delete-lambda-layer-versions.sh rename to scripts/aws/aws-delete-lambda-layer-versions.sh index f467f9398b..dcbd2f9c65 100755 --- a/scripts/aws-delete-lambda-layer-versions.sh +++ b/scripts/aws/aws-delete-lambda-layer-versions.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # # Deletes all versions of the layer specified in LAYER_NAME in one region. +# Use with caution! # set -euo pipefail diff --git a/scripts/aws-deploy-local-layer.sh b/scripts/aws/aws-deploy-local-layer.sh similarity index 81% rename from scripts/aws-deploy-local-layer.sh rename to scripts/aws/aws-deploy-local-layer.sh index 56f2087596..ee7b3e45c0 100755 --- a/scripts/aws-deploy-local-layer.sh +++ b/scripts/aws/aws-deploy-local-layer.sh @@ -1,9 +1,8 @@ #!/usr/bin/env bash # -# Builds and deploys the Sentry AWS Lambda layer (including the Sentry SDK and the Sentry Lambda Extension) +# Builds and deploys the `SentryPythonServerlessSDK-local-dev` AWS Lambda layer (containing the Sentry SDK) # # The currently checked out version of the SDK in your local directory is used. -# The latest version of the Lambda Extension is fetched from the Sentry Release Registry. # set -euo pipefail diff --git a/scripts/aws_lambda_functions/README.md b/scripts/aws_lambda_functions/README.md deleted file mode 100644 index e07b445d5b..0000000000 --- a/scripts/aws_lambda_functions/README.md +++ /dev/null @@ -1,4 +0,0 @@ -aws_lambda_functions -==================== - -In this directory you can place AWS Lambda functions that are used for administrative tasks (or whatever) \ No newline at end of file diff --git a/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/README.md b/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/README.md deleted file mode 100644 index de1120a026..0000000000 --- a/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/README.md +++ /dev/null @@ -1,13 +0,0 @@ -sentryPythonDeleteTestFunctions -=============================== - -This AWS Lambda function deletes all AWS Lambda functions in the current AWS account that are prefixed with `test_`. -The functions that are deleted are created by the Google Actions CI checks running on every PR of the `sentry-python` repository. - -The Lambda function has been deployed here: -- AWS Account ID: `943013980633` -- Region: `us-east-1` -- Function ARN: `arn:aws:lambda:us-east-1:943013980633:function:sentryPythonDeleteTestFunctions` - -This function also emits Sentry Metrics and Sentry Crons checkins to the `sentry-python` project in the `Sentry SDKs` organisation on Sentry.io: -https://sentry-sdks.sentry.io/projects/sentry-python/?project=5461230 \ No newline at end of file diff --git a/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/lambda_function.py b/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/lambda_function.py deleted file mode 100644 index ce7afb6aa4..0000000000 --- a/scripts/aws_lambda_functions/sentryPythonDeleteTestFunctions/lambda_function.py +++ /dev/null @@ -1,55 +0,0 @@ -import boto3 -import sentry_sdk - - -monitor_slug = "python-sdk-aws-lambda-tests-cleanup" -monitor_config = { - "schedule": { - "type": "crontab", - "value": "0 12 * * 0", # 12 o'clock on Sunday - }, - "timezone": "UTC", - "checkin_margin": 2, - "max_runtime": 20, - "failure_issue_threshold": 1, - "recovery_threshold": 1, -} - - -@sentry_sdk.crons.monitor(monitor_slug=monitor_slug) -def delete_lambda_functions(prefix="test_"): - """ - Delete all AWS Lambda functions in the current account - where the function name matches the prefix - """ - client = boto3.client("lambda", region_name="us-east-1") - functions_deleted = 0 - - functions_paginator = client.get_paginator("list_functions") - for functions_page in functions_paginator.paginate(): - for func in functions_page["Functions"]: - function_name = func["FunctionName"] - if function_name.startswith(prefix): - try: - response = client.delete_function( - FunctionName=func["FunctionArn"], - ) - functions_deleted += 1 - except Exception as ex: - print(f"Got exception: {ex}") - - return functions_deleted - - -def lambda_handler(event, context): - functions_deleted = delete_lambda_functions() - - sentry_sdk.metrics.gauge( - key="num_aws_functions_deleted", - value=functions_deleted, - ) - - return { - "statusCode": 200, - "body": f"{functions_deleted} AWS Lambda functions deleted successfully.", - } diff --git a/scripts/populate_tox/tox.jinja b/scripts/populate_tox/tox.jinja index 81ab17c919..9da986a35a 100644 --- a/scripts/populate_tox/tox.jinja +++ b/scripts/populate_tox/tox.jinja @@ -57,10 +57,7 @@ envlist = {py3.8,py3.11,py3.12}-asyncpg-latest # AWS Lambda - # The aws_lambda tests deploy to the real AWS and have their own - # matrix of Python versions to run the test lambda function in. - # see `lambda_runtime` fixture in tests/integrations/aws_lambda.py - {py3.9}-aws_lambda + {py3.8,py3.9,py3.11,py3.13}-aws_lambda # Beam {py3.7}-beam-v{2.12} @@ -250,7 +247,12 @@ deps = asyncpg: pytest-asyncio # AWS Lambda + aws_lambda: aws-cdk-lib + aws_lambda: aws-sam-cli aws_lambda: boto3 + aws_lambda: fastapi + aws_lambda: requests + aws_lambda: uvicorn # Beam beam-v2.12: apache-beam~=2.12.0 @@ -528,8 +530,6 @@ setenv = socket: TESTPATH=tests/integrations/socket passenv = - SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID - SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY SENTRY_PYTHON_TEST_POSTGRES_HOST SENTRY_PYTHON_TEST_POSTGRES_USER SENTRY_PYTHON_TEST_POSTGRES_PASSWORD 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 5218b0675f..293af897c9 100755 --- a/scripts/split_tox_gh_actions/split_tox_gh_actions.py +++ b/scripts/split_tox_gh_actions/split_tox_gh_actions.py @@ -43,11 +43,7 @@ "clickhouse_driver", } -FRAMEWORKS_NEEDING_AWS = { - "aws_lambda", -} - -FRAMEWORKS_NEEDING_GITHUB_SECRETS = { +FRAMEWORKS_NEEDING_DOCKER = { "aws_lambda", } @@ -65,12 +61,8 @@ "openai", "huggingface_hub", ], - "AWS": [ - # this is separate from Cloud Computing because only this one test suite - # needs to run with access to GitHub secrets - "aws_lambda", - ], "Cloud": [ + "aws_lambda", "boto3", "chalice", "cloud_resource_context", @@ -292,13 +284,10 @@ def render_template(group, frameworks, py_versions_pinned, py_versions_latest): "group": group, "frameworks": frameworks, "categories": sorted(categories), - "needs_aws_credentials": bool(set(frameworks) & FRAMEWORKS_NEEDING_AWS), "needs_clickhouse": bool(set(frameworks) & FRAMEWORKS_NEEDING_CLICKHOUSE), + "needs_docker": bool(set(frameworks) & FRAMEWORKS_NEEDING_DOCKER), "needs_postgres": bool(set(frameworks) & FRAMEWORKS_NEEDING_POSTGRES), "needs_redis": bool(set(frameworks) & FRAMEWORKS_NEEDING_REDIS), - "needs_github_secrets": bool( - set(frameworks) & FRAMEWORKS_NEEDING_GITHUB_SECRETS - ), "py_versions": { category: [f'"{version}"' for version in _normalize_py_versions(versions)] for category, versions in py_versions.items() diff --git a/scripts/split_tox_gh_actions/templates/base.jinja b/scripts/split_tox_gh_actions/templates/base.jinja index e69b6f9134..75c988e32a 100644 --- a/scripts/split_tox_gh_actions/templates/base.jinja +++ b/scripts/split_tox_gh_actions/templates/base.jinja @@ -13,15 +13,7 @@ on: - release/** - potel-base - {% if needs_github_secrets %} - # XXX: We are using `pull_request_target` instead of `pull_request` because we want - # this to run on forks with access to the secrets necessary to run the test suite. - # Prefer to use `pull_request` when possible. - pull_request_target: - types: [labeled, opened, reopened, synchronize] - {% else %} pull_request: - {% endif %} # Cancel in progress workflows on pull_requests. # https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value @@ -31,27 +23,13 @@ concurrency: permissions: contents: read - {% if needs_github_secrets %} - # `write` is needed to remove the `Trigger: tests using secrets` label - pull-requests: write - {% endif %} env: -{% if needs_aws_credentials %} -{% raw %} - SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID: ${{ secrets.SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID }} - SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY: ${{ secrets.SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY }} -{% endraw %} -{% endif %} BUILD_CACHE_KEY: {% raw %}${{ github.sha }}{% endraw %} CACHED_BUILD_PATHS: | {% raw %}${{ github.workspace }}/dist-serverless{% endraw %} jobs: -{% if needs_github_secrets %} -{% include "check_permissions.jinja" %} -{% endif %} - {% for category in categories %} {% include "test_group.jinja" %} {% endfor %} diff --git a/scripts/split_tox_gh_actions/templates/check_permissions.jinja b/scripts/split_tox_gh_actions/templates/check_permissions.jinja deleted file mode 100644 index 390f447856..0000000000 --- a/scripts/split_tox_gh_actions/templates/check_permissions.jinja +++ /dev/null @@ -1,30 +0,0 @@ - check-permissions: - name: permissions check - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v4.2.2 - with: - persist-credentials: false - - - name: Check permissions on PR - if: github.event_name == 'pull_request_target' - run: | - {% raw %} - python3 -uS .github/workflows/scripts/trigger_tests_on_label.py \ - --repo-id ${{ github.event.repository.id }} \ - --pr ${{ github.event.number }} \ - --event ${{ github.event.action }} \ - --username "$ARG_USERNAME" \ - --label-names "$ARG_LABEL_NAMES" - {% endraw %} - env: - {% raw %} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # these can contain special characters - ARG_USERNAME: ${{ github.event.pull_request.user.login }} - ARG_LABEL_NAMES: ${{ toJSON(github.event.pull_request.labels.*.name) }} - {% endraw %} - - - name: Check permissions on repo branch - if: github.event_name == 'push' - run: true diff --git a/scripts/split_tox_gh_actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja index 01f9cd56ec..9fcc0b1527 100644 --- a/scripts/split_tox_gh_actions/templates/test_group.jinja +++ b/scripts/split_tox_gh_actions/templates/test_group.jinja @@ -12,10 +12,12 @@ # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 os: [ubuntu-20.04] - {% if needs_github_secrets %} - needs: check-permissions + {% if needs_docker %} + services: + docker: + image: docker:dind # Required for Docker network management + options: --privileged # Required for Docker-in-Docker operations {% endif %} - {% if needs_postgres %} services: postgres: @@ -40,12 +42,6 @@ steps: - uses: actions/checkout@v4.2.2 - {% if needs_github_secrets %} - {% raw %} - with: - ref: ${{ github.event.pull_request.head.sha || github.ref }} - {% endraw %} - {% endif %} - uses: actions/setup-python@v5 with: python-version: {% raw %}${{ matrix.python-version }}{% endraw %} diff --git a/tests/integrations/aws_lambda/__init__.py b/tests/integrations/aws_lambda/__init__.py index 71eb245353..449f4dc95d 100644 --- a/tests/integrations/aws_lambda/__init__.py +++ b/tests/integrations/aws_lambda/__init__.py @@ -1,3 +1,5 @@ import pytest pytest.importorskip("boto3") +pytest.importorskip("fastapi") +pytest.importorskip("uvicorn") diff --git a/tests/integrations/aws_lambda/client.py b/tests/integrations/aws_lambda/client.py deleted file mode 100644 index afacf6fc42..0000000000 --- a/tests/integrations/aws_lambda/client.py +++ /dev/null @@ -1,408 +0,0 @@ -import base64 -import boto3 -import glob -import hashlib -import os -import subprocess -import sys -import tempfile - -from sentry_sdk.consts import VERSION as SDK_VERSION -from sentry_sdk.utils import get_git_revision - -AWS_REGION_NAME = "us-east-1" -AWS_CREDENTIALS = { - "aws_access_key_id": os.environ["SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID"], - "aws_secret_access_key": os.environ["SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY"], -} -AWS_LAMBDA_EXECUTION_ROLE_NAME = "lambda-ex" -AWS_LAMBDA_EXECUTION_ROLE_ARN = None - - -def _install_dependencies(base_dir, subprocess_kwargs): - """ - Installs dependencies for AWS Lambda function - """ - setup_cfg = os.path.join(base_dir, "setup.cfg") - with open(setup_cfg, "w") as f: - f.write("[install]\nprefix=") - - # Install requirements for Lambda Layer (these are more limited than the SDK requirements, - # because Lambda does not support the newest versions of some packages) - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "-r", - "requirements-aws-lambda-layer.txt", - "--target", - base_dir, - ], - **subprocess_kwargs, - ) - # Install requirements used for testing - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - "mock==3.0.0", - "funcsigs", - "--target", - base_dir, - ], - **subprocess_kwargs, - ) - # Create a source distribution of the Sentry SDK (in parent directory of base_dir) - subprocess.check_call( - [ - sys.executable, - "setup.py", - "sdist", - "--dist-dir", - os.path.dirname(base_dir), - ], - **subprocess_kwargs, - ) - # Install the created Sentry SDK source distribution into the target directory - # Do not install the dependencies of the SDK, because they where installed by requirements-aws-lambda-layer.txt above - source_distribution_archive = glob.glob( - "{}/*.tar.gz".format(os.path.dirname(base_dir)) - )[0] - subprocess.check_call( - [ - sys.executable, - "-m", - "pip", - "install", - source_distribution_archive, - "--no-deps", - "--target", - base_dir, - ], - **subprocess_kwargs, - ) - - -def _create_lambda_function_zip(base_dir): - """ - Zips the given base_dir omitting Python cache files - """ - subprocess.run( - [ - "zip", - "-q", - "-x", - "**/__pycache__/*", - "-r", - "lambda-function-package.zip", - "./", - ], - cwd=base_dir, - check=True, - ) - - -def _create_lambda_package( - base_dir, code, initial_handler, layer, syntax_check, subprocess_kwargs -): - """ - Creates deployable packages (as zip files) for AWS Lambda function - and optional the accompanying Sentry Lambda layer - """ - if initial_handler: - # If Initial handler value is provided i.e. it is not the default - # `test_lambda.test_handler`, then create another dir level so that our path is - # test_dir.test_lambda.test_handler - test_dir_path = os.path.join(base_dir, "test_dir") - python_init_file = os.path.join(test_dir_path, "__init__.py") - os.makedirs(test_dir_path) - with open(python_init_file, "w"): - # Create __init__ file to make it a python package - pass - - test_lambda_py = os.path.join(base_dir, "test_dir", "test_lambda.py") - else: - test_lambda_py = os.path.join(base_dir, "test_lambda.py") - - with open(test_lambda_py, "w") as f: - f.write(code) - - if syntax_check: - # Check file for valid syntax first, and that the integration does not - # crash when not running in Lambda (but rather a local deployment tool - # such as chalice's) - subprocess.check_call([sys.executable, test_lambda_py]) - - if layer is None: - _install_dependencies(base_dir, subprocess_kwargs) - _create_lambda_function_zip(base_dir) - - else: - _create_lambda_function_zip(base_dir) - - # Create Lambda layer zip package - from scripts.build_aws_lambda_layer import build_packaged_zip - - build_packaged_zip( - base_dir=base_dir, - make_dist=True, - out_zip_filename="lambda-layer-package.zip", - ) - - -def _get_or_create_lambda_execution_role(): - global AWS_LAMBDA_EXECUTION_ROLE_ARN - - policy = """{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - }, - "Action": "sts:AssumeRole" - } - ] - } - """ - iam_client = boto3.client( - "iam", - region_name=AWS_REGION_NAME, - **AWS_CREDENTIALS, - ) - - try: - response = iam_client.get_role(RoleName=AWS_LAMBDA_EXECUTION_ROLE_NAME) - AWS_LAMBDA_EXECUTION_ROLE_ARN = response["Role"]["Arn"] - except iam_client.exceptions.NoSuchEntityException: - # create role for lambda execution - response = iam_client.create_role( - RoleName=AWS_LAMBDA_EXECUTION_ROLE_NAME, - AssumeRolePolicyDocument=policy, - ) - AWS_LAMBDA_EXECUTION_ROLE_ARN = response["Role"]["Arn"] - - # attach policy to role - iam_client.attach_role_policy( - RoleName=AWS_LAMBDA_EXECUTION_ROLE_NAME, - PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ) - - -def get_boto_client(): - _get_or_create_lambda_execution_role() - - return boto3.client( - "lambda", - region_name=AWS_REGION_NAME, - **AWS_CREDENTIALS, - ) - - -def run_lambda_function( - client, - runtime, - code, - payload, - add_finalizer, - syntax_check=True, - timeout=30, - layer=None, - initial_handler=None, - subprocess_kwargs=(), -): - """ - Creates a Lambda function with the given code, and invokes it. - - If the same code is run multiple times the function will NOT be - created anew each time but the existing function will be reused. - """ - subprocess_kwargs = dict(subprocess_kwargs) - - # Making a unique function name depending on all the code that is run in it (function code plus SDK version) - # The name needs to be short so the generated event/envelope json blobs are small enough to be output - # in the log result of the Lambda function. - rev = get_git_revision() or SDK_VERSION - function_hash = hashlib.shake_256((code + rev).encode("utf-8")).hexdigest(6) - fn_name = "test_{}".format(function_hash) - full_fn_name = "{}_{}".format( - fn_name, runtime.replace(".", "").replace("python", "py") - ) - - function_exists_in_aws = True - try: - client.get_function( - FunctionName=full_fn_name, - ) - print( - "Lambda function in AWS already existing, taking it (and do not create a local one)" - ) - except client.exceptions.ResourceNotFoundException: - function_exists_in_aws = False - - if not function_exists_in_aws: - tmp_base_dir = tempfile.gettempdir() - base_dir = os.path.join(tmp_base_dir, fn_name) - dir_already_existing = os.path.isdir(base_dir) - - if dir_already_existing: - print("Local Lambda function directory already exists, skipping creation") - - if not dir_already_existing: - os.mkdir(base_dir) - _create_lambda_package( - base_dir, code, initial_handler, layer, syntax_check, subprocess_kwargs - ) - - @add_finalizer - def clean_up(): - # this closes the web socket so we don't get a - # ResourceWarning: unclosed - # warning on every test - # based on https://github.com/boto/botocore/pull/1810 - # (if that's ever merged, this can just become client.close()) - session = client._endpoint.http_session - managers = [session._manager] + list(session._proxy_managers.values()) - for manager in managers: - manager.clear() - - layers = [] - environment = {} - handler = initial_handler or "test_lambda.test_handler" - - if layer is not None: - with open( - os.path.join(base_dir, "lambda-layer-package.zip"), "rb" - ) as lambda_layer_zip: - response = client.publish_layer_version( - LayerName="python-serverless-sdk-test", - Description="Created as part of testsuite for getsentry/sentry-python", - Content={"ZipFile": lambda_layer_zip.read()}, - ) - - layers = [response["LayerVersionArn"]] - handler = ( - "sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler" - ) - environment = { - "Variables": { - "SENTRY_INITIAL_HANDLER": initial_handler - or "test_lambda.test_handler", - "SENTRY_DSN": "https://123abc@example.com/123", - "SENTRY_TRACES_SAMPLE_RATE": "1.0", - } - } - - try: - with open( - os.path.join(base_dir, "lambda-function-package.zip"), "rb" - ) as lambda_function_zip: - client.create_function( - Description="Created as part of testsuite for getsentry/sentry-python", - FunctionName=full_fn_name, - Runtime=runtime, - Timeout=timeout, - Role=AWS_LAMBDA_EXECUTION_ROLE_ARN, - Handler=handler, - Code={"ZipFile": lambda_function_zip.read()}, - Environment=environment, - Layers=layers, - ) - - waiter = client.get_waiter("function_active_v2") - waiter.wait(FunctionName=full_fn_name) - except client.exceptions.ResourceConflictException: - print( - "Lambda function already exists, this is fine, we will just invoke it." - ) - - response = client.invoke( - FunctionName=full_fn_name, - InvocationType="RequestResponse", - LogType="Tail", - Payload=payload, - ) - - assert 200 <= response["StatusCode"] < 300, response - return response - - -# This is for inspecting new Python runtime environments in AWS Lambda -# If you need to debug a new runtime, use this REPL to run arbitrary Python or bash commands -# in that runtime in a Lambda function: -# -# pip3 install click -# python3 tests/integrations/aws_lambda/client.py --runtime=python4.0 -# - - -_REPL_CODE = """ -import os - -def test_handler(event, context): - line = {line!r} - if line.startswith(">>> "): - exec(line[4:]) - elif line.startswith("$ "): - os.system(line[2:]) - else: - print("Start a line with $ or >>>") - - return b"" -""" - -try: - import click -except ImportError: - pass -else: - - @click.command() - @click.option( - "--runtime", required=True, help="name of the runtime to use, eg python3.11" - ) - @click.option("--verbose", is_flag=True, default=False) - def repl(runtime, verbose): - """ - Launch a "REPL" against AWS Lambda to inspect their runtime. - """ - - cleanup = [] - client = get_boto_client() - - print("Start a line with `$ ` to run shell commands, or `>>> ` to run Python") - - while True: - line = input() - - response = run_lambda_function( - client, - runtime, - _REPL_CODE.format(line=line), - b"", - cleanup.append, - subprocess_kwargs=( - { - "stdout": subprocess.DEVNULL, - "stderr": subprocess.DEVNULL, - } - if not verbose - else {} - ), - ) - - for line in base64.b64decode(response["LogResult"]).splitlines(): - print(line.decode("utf8")) - - for f in cleanup: - f() - - cleanup = [] - - if __name__ == "__main__": - repl() diff --git a/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py b/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py new file mode 100644 index 0000000000..875b984e2a --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions/BasicException/index.py @@ -0,0 +1,6 @@ +def handler(event, context): + raise RuntimeError("Oh!") + + return { + "event": event, + } diff --git a/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py b/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py new file mode 100644 index 0000000000..257fea04f0 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions/BasicOk/index.py @@ -0,0 +1,4 @@ +def handler(event, context): + return { + "event": event, + } diff --git a/tests/integrations/aws_lambda/lambda_functions/InitError/index.py b/tests/integrations/aws_lambda/lambda_functions/InitError/index.py new file mode 100644 index 0000000000..20b4fcc111 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions/InitError/index.py @@ -0,0 +1,3 @@ +# We have no handler() here and try to call a non-existing function. + +func() # noqa: F821 diff --git a/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py b/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py new file mode 100644 index 0000000000..01334bbfbc --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions/TimeoutError/index.py @@ -0,0 +1,8 @@ +import time + + +def handler(event, context): + time.sleep(15) + return { + "event": event, + } diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore new file mode 100644 index 0000000000..ee0b7b9305 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore \ No newline at end of file diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py new file mode 100644 index 0000000000..12f43f0009 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceDisabled/index.py @@ -0,0 +1,14 @@ +import os +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=None, # this is the default, just added for clarity + integrations=[AwsLambdaIntegration()], +) + + +def handler(event, context): + raise Exception("Oh!") diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore new file mode 100644 index 0000000000..ee0b7b9305 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore \ No newline at end of file diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py new file mode 100644 index 0000000000..c694299682 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/RaiseErrorPerformanceEnabled/index.py @@ -0,0 +1,14 @@ +import os +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + integrations=[AwsLambdaIntegration()], +) + + +def handler(event, context): + raise Exception("Oh!") diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore new file mode 100644 index 0000000000..ee0b7b9305 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/.gitignore @@ -0,0 +1,11 @@ +# Need to add some ignore rules in this directory, because the unit tests will add the Sentry SDK and its dependencies +# into this directory to create a Lambda function package that contains everything needed to instrument a Lambda function using Sentry. + +# Ignore everything +* + +# But not index.py +!index.py + +# And not .gitignore itself +!.gitignore \ No newline at end of file diff --git a/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py new file mode 100644 index 0000000000..ce797faf71 --- /dev/null +++ b/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/TracesSampler/index.py @@ -0,0 +1,49 @@ +import json +import os +import sentry_sdk +from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration + +# Global variables to store sampling context for verification +sampling_context_data = { + "aws_event_present": False, + "aws_context_present": False, + "event_data": None, +} + + +def trace_sampler(sampling_context): + # Store the sampling context for verification + global sampling_context_data + + # Check if aws_event and aws_context are in the sampling_context + if "aws_event" in sampling_context: + sampling_context_data["aws_event_present"] = True + sampling_context_data["event_data"] = sampling_context["aws_event"] + + if "aws_context" in sampling_context: + sampling_context_data["aws_context_present"] = True + + print("Sampling context data:", sampling_context_data) + return 1.0 # Always sample + + +sentry_sdk.init( + dsn=os.environ.get("SENTRY_DSN"), + traces_sample_rate=1.0, + traces_sampler=trace_sampler, + integrations=[AwsLambdaIntegration()], +) + + +def handler(event, context): + # Return the sampling context data for verification + return { + "statusCode": 200, + "body": json.dumps( + { + "message": "Hello from Lambda with embedded Sentry SDK!", + "event": event, + "sampling_context_data": sampling_context_data, + } + ), + } diff --git a/tests/integrations/aws_lambda/test_aws.py b/tests/integrations/aws_lambda/test_aws.py deleted file mode 100644 index 8bbd33505b..0000000000 --- a/tests/integrations/aws_lambda/test_aws.py +++ /dev/null @@ -1,898 +0,0 @@ -""" -# AWS Lambda System Tests - -This testsuite uses boto3 to upload actual Lambda functions to AWS Lambda and invoke them. - -For running test locally you need to set these env vars: -(You can find the values in the Sentry password manager by searching for "AWS Lambda for Python SDK Tests"). - - export SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID="..." - export SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY="..." - - -You can use `scripts/aws-cleanup.sh` to delete all files generated by this test suite. - - -If you need to debug a new runtime, use this REPL to run arbitrary Python or bash commands -in that runtime in a Lambda function: (see the bottom of client.py for more information.) - - pip3 install click - python3 tests/integrations/aws_lambda/client.py --runtime=python4.0 - -IMPORTANT: - -During running of this test suite temporary folders will be created for compiling the Lambda functions. -This temporary folders will not be cleaned up. This is because in CI generated files have to be shared -between tests and thus the folders can not be deleted right after use. - -If you run your tests locally, you need to clean up the temporary folders manually. The location of -the temporary folders is printed when running a test. -""" - -import base64 -import json -import re -from textwrap import dedent - -import pytest - -RUNTIMES_TO_TEST = [ - "python3.8", - "python3.10", - "python3.12", - "python3.13", -] - -LAMBDA_PRELUDE = """ -from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration, get_lambda_bootstrap -import sentry_sdk -import json -import time - -from sentry_sdk.transport import Transport - -def truncate_data(data): - # AWS Lambda truncates the log output to 4kb, which is small enough to miss - # parts of even a single error-event/transaction-envelope pair if considered - # in full, so only grab the data we need. - - cleaned_data = {} - - if data.get("type") is not None: - cleaned_data["type"] = data["type"] - - if data.get("contexts") is not None: - cleaned_data["contexts"] = {} - - if data["contexts"].get("trace") is not None: - cleaned_data["contexts"]["trace"] = data["contexts"].get("trace") - - if data.get("transaction") is not None: - cleaned_data["transaction"] = data.get("transaction") - - if data.get("request") is not None: - cleaned_data["request"] = data.get("request") - - if data.get("tags") is not None: - cleaned_data["tags"] = data.get("tags") - - if data.get("exception") is not None: - cleaned_data["exception"] = data.get("exception") - - for value in cleaned_data["exception"]["values"]: - for frame in value.get("stacktrace", {}).get("frames", []): - del frame["vars"] - del frame["pre_context"] - del frame["context_line"] - del frame["post_context"] - - if data.get("extra") is not None: - cleaned_data["extra"] = {} - - for key in data["extra"].keys(): - if key == "lambda": - for lambda_key in data["extra"]["lambda"].keys(): - if lambda_key in ["function_name"]: - cleaned_data["extra"].setdefault("lambda", {})[lambda_key] = data["extra"]["lambda"][lambda_key] - 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].split("=")[0] - - if data.get("level") is not None: - cleaned_data["level"] = data.get("level") - - if data.get("message") is not None: - cleaned_data["message"] = data.get("message") - - if "contexts" not in cleaned_data: - raise Exception(json.dumps(data)) - - return cleaned_data - -def event_processor(event): - return truncate_data(event) - -def envelope_processor(envelope): - (item,) = envelope.items - item_json = json.loads(item.get_bytes()) - - return truncate_data(item_json) - - -class TestTransport(Transport): - def capture_envelope(self, envelope): - envelope_items = envelope_processor(envelope) - print("\\nENVELOPE: {}\\n".format(json.dumps(envelope_items))) - -def init_sdk(timeout_warning=False, **extra_init_args): - sentry_sdk.init( - dsn="https://123abc@example.com/123", - transport=TestTransport, - integrations=[AwsLambdaIntegration(timeout_warning=timeout_warning)], - shutdown_timeout=10, - **extra_init_args - ) -""" - - -@pytest.fixture -def lambda_client(): - from tests.integrations.aws_lambda.client import get_boto_client - - return get_boto_client() - - -@pytest.fixture(params=RUNTIMES_TO_TEST) -def lambda_runtime(request): - return request.param - - -@pytest.fixture -def run_lambda_function(request, lambda_client, lambda_runtime): - def inner( - code, payload, timeout=30, syntax_check=True, layer=None, initial_handler=None - ): - from tests.integrations.aws_lambda.client import run_lambda_function - - response = run_lambda_function( - client=lambda_client, - runtime=lambda_runtime, - code=code, - payload=payload, - add_finalizer=request.addfinalizer, - timeout=timeout, - syntax_check=syntax_check, - layer=layer, - initial_handler=initial_handler, - ) - - # Make sure the "ENVELOPE:" and "EVENT:" log entries are always starting a new line. (Sometimes they don't.) - response["LogResult"] = ( - base64.b64decode(response["LogResult"]) - .replace(b"EVENT:", b"\nEVENT:") - .replace(b"ENVELOPE:", b"\nENVELOPE:") - .splitlines() - ) - response["Payload"] = json.loads(response["Payload"].read().decode("utf-8")) - del response["ResponseMetadata"] - - envelope_items = [] - - for line in response["LogResult"]: - print("AWS:", line) - if line.startswith(b"ENVELOPE: "): - line = line[len(b"ENVELOPE: ") :] - envelope_items.append(json.loads(line.decode("utf-8"))) - else: - continue - - return envelope_items, response - - return inner - - -def test_basic(run_lambda_function): - envelope_items, response = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk() - - def test_handler(event, context): - raise Exception("Oh!") - """ - ), - b'{"foo": "bar"}', - ) - - assert response["FunctionError"] == "Unhandled" - - (event,) = envelope_items - assert event["level"] == "error" - (exception,) = event["exception"]["values"] - assert exception["type"] == "Exception" - assert exception["value"] == "Oh!" - - (frame1,) = exception["stacktrace"]["frames"] - assert frame1["filename"] == "test_lambda.py" - assert frame1["abs_path"] == "/var/task/test_lambda.py" - assert frame1["function"] == "test_handler" - - assert frame1["in_app"] is True - - assert exception["mechanism"]["type"] == "aws_lambda" - assert not exception["mechanism"]["handled"] - - 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 not re.search("(=;|=$)", logs_url) - assert event["extra"]["cloudwatch logs"]["log_group"].startswith( - "/aws/lambda/test_" - ) - - log_stream_re = "^[0-9]{4}/[0-9]{2}/[0-9]{2}/\\[[^\\]]+][a-f0-9]+$" - log_stream = event["extra"]["cloudwatch logs"]["log_stream"] - - assert re.match(log_stream_re, log_stream) - - -def test_initialization_order(run_lambda_function): - """Zappa lazily imports our code, so by the time we monkeypatch the handler - as seen by AWS already runs. At this point at least draining the queue - should work.""" - - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - def test_handler(event, context): - init_sdk() - sentry_sdk.capture_exception(Exception("Oh!")) - """ - ), - b'{"foo": "bar"}', - ) - - (event,) = envelope_items - - assert event["level"] == "error" - (exception,) = event["exception"]["values"] - assert exception["type"] == "Exception" - assert exception["value"] == "Oh!" - - -def test_request_data(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk() - def test_handler(event, context): - sentry_sdk.capture_message("hi") - return "ok" - """ - ), - payload=b""" - { - "resource": "/asd", - "path": "/asd", - "httpMethod": "GET", - "headers": { - "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", - "User-Agent": "custom", - "X-Forwarded-Proto": "https" - }, - "queryStringParameters": { - "bonkers": "true" - }, - "pathParameters": null, - "stageVariables": null, - "requestContext": { - "identity": { - "sourceIp": "213.47.147.207", - "userArn": "42" - } - }, - "body": null, - "isBase64Encoded": false - } - """, - ) - - (event,) = envelope_items - - assert event["request"] == { - "headers": { - "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", - "User-Agent": "custom", - "X-Forwarded-Proto": "https", - }, - "method": "GET", - "query_string": {"bonkers": "true"}, - "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", - } - - -def test_init_error(run_lambda_function, lambda_runtime): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk() - func() - """ - ), - b'{"foo": "bar"}', - syntax_check=False, - ) - - # 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" - - -def test_timeout_error(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(timeout_warning=True) - - def test_handler(event, context): - time.sleep(10) - return 0 - """ - ), - b'{"foo": "bar"}', - timeout=2, - ) - - (event,) = envelope_items - assert event["level"] == "error" - (exception,) = event["exception"]["values"] - assert exception["type"] == "ServerlessTimeoutWarning" - assert exception["value"] in ( - "WARNING : Function is expected to get timed out. Configured timeout duration = 3 seconds.", - "WARNING : Function is expected to get timed out. Configured timeout duration = 2 seconds.", - ) - - assert exception["mechanism"]["type"] == "threading" - assert not exception["mechanism"]["handled"] - - 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 not re.search("(=;|=$)", logs_url) - assert event["extra"]["cloudwatch logs"]["log_group"].startswith( - "/aws/lambda/test_" - ) - - log_stream_re = "^[0-9]{4}/[0-9]{2}/[0-9]{2}/\\[[^\\]]+][a-f0-9]+$" - log_stream = event["extra"]["cloudwatch logs"]["log_stream"] - - assert re.match(log_stream_re, log_stream) - - -def test_performance_no_error(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - return "test_string" - """ - ), - b'{"foo": "bar"}', - ) - - (envelope,) = envelope_items - - assert envelope["type"] == "transaction" - assert envelope["contexts"]["trace"]["op"] == "function.aws" - assert envelope["transaction"].startswith("test_") - assert envelope["transaction"] in envelope["request"]["url"] - - -def test_performance_error(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - raise Exception("Oh!") - """ - ), - b'{"foo": "bar"}', - ) - - ( - error_event, - transaction_event, - ) = envelope_items - - assert error_event["level"] == "error" - (exception,) = error_event["exception"]["values"] - assert exception["type"] == "Exception" - assert exception["value"] == "Oh!" - - assert transaction_event["type"] == "transaction" - assert transaction_event["contexts"]["trace"]["op"] == "function.aws" - assert transaction_event["transaction"].startswith("test_") - assert transaction_event["transaction"] in transaction_event["request"]["url"] - - -@pytest.mark.parametrize( - "aws_event, has_request_data, batch_size", - [ - (b"1231", False, 1), - (b"11.21", False, 1), - (b'"Good dog!"', False, 1), - (b"true", False, 1), - ( - b""" - [ - {"good dog": "Maisey"}, - {"good dog": "Charlie"}, - {"good dog": "Cory"}, - {"good dog": "Bodhi"} - ] - """, - False, - 4, - ), - ( - b""" - [ - { - "headers": { - "Host": "x1.io", - "X-Forwarded-Proto": "https" - }, - "httpMethod": "GET", - "path": "/1", - "queryStringParameters": { - "done": "f" - }, - "d": "D1" - }, - { - "headers": { - "Host": "x2.io", - "X-Forwarded-Proto": "http" - }, - "httpMethod": "POST", - "path": "/2", - "queryStringParameters": { - "done": "t" - }, - "d": "D2" - } - ] - """, - True, - 2, - ), - (b"[]", False, 1), - ], -) -def test_non_dict_event( - run_lambda_function, - aws_event, - has_request_data, - batch_size, - DictionaryContaining, # noqa:N803 -): - envelope_items, response = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - raise Exception("Oh?") - """ - ), - aws_event, - ) - - assert response["FunctionError"] == "Unhandled" - - ( - error_event, - transaction_event, - ) = envelope_items - assert error_event["level"] == "error" - assert error_event["contexts"]["trace"]["op"] == "function.aws" - - function_name = error_event["extra"]["lambda"]["function_name"] - assert function_name.startswith("test_") - assert error_event["transaction"] == function_name - - exception = error_event["exception"]["values"][0] - assert exception["type"] == "Exception" - assert exception["value"] == "Oh?" - assert exception["mechanism"]["type"] == "aws_lambda" - - assert transaction_event["type"] == "transaction" - assert transaction_event["contexts"]["trace"] == DictionaryContaining( - error_event["contexts"]["trace"] - ) - assert transaction_event["contexts"]["trace"]["status"] == "internal_error" - assert transaction_event["transaction"] == error_event["transaction"] - assert transaction_event["request"]["url"] == error_event["request"]["url"] - - if has_request_data: - request_data = { - "headers": {"Host": "x1.io", "X-Forwarded-Proto": "https"}, - "method": "GET", - "url": "https://x1.io/1", - "query_string": { - "done": "f", - }, - } - else: - request_data = {"url": "awslambda:///{}".format(function_name)} - - assert error_event["request"] == request_data - assert transaction_event["request"] == request_data - - if batch_size > 1: - assert error_event["tags"]["batch_size"] == batch_size - assert error_event["tags"]["batch_request"] is True - assert transaction_event["tags"]["batch_size"] == batch_size - assert transaction_event["tags"]["batch_request"] is True - - -def test_traces_sampler_gets_correct_values_in_sampling_context( - run_lambda_function, - DictionaryContaining, # noqa: N803 - ObjectDescribedBy, # noqa: N803 - StringContaining, # noqa: N803 -): - # TODO: This whole thing is a little hacky, specifically around the need to - # get `conftest.py` code into the AWS runtime, which is why there's both - # `inspect.getsource` and a copy of `_safe_is_equal` included directly in - # the code below. Ideas which have been discussed to fix this: - - # - Include the test suite as a module installed in the package which is - # shot up to AWS - # - In client.py, copy `conftest.py` (or wherever the necessary code lives) - # from the test suite into the main SDK directory so it gets included as - # "part of the SDK" - - # It's also worth noting why it's necessary to run the assertions in the AWS - # runtime rather than asserting on side effects the way we do with events - # and envelopes. The reasons are two-fold: - - # - We're testing against the `LambdaContext` class, which only exists in - # the AWS runtime - # - If we were to transmit call args data they way we transmit event and - # envelope data (through JSON), we'd quickly run into the problem that all - # sorts of stuff isn't serializable by `json.dumps` out of the box, up to - # and including `datetime` objects (so anything with a timestamp is - # automatically out) - - # Perhaps these challenges can be solved in a cleaner and more systematic - # way if we ever decide to refactor the entire AWS testing apparatus. - - import inspect - - _, response = run_lambda_function( - LAMBDA_PRELUDE - + dedent(inspect.getsource(StringContaining)) - + dedent(inspect.getsource(DictionaryContaining)) - + dedent(inspect.getsource(ObjectDescribedBy)) - + dedent( - """ - from unittest import mock - - def _safe_is_equal(x, y): - # copied from conftest.py - see docstring and comments there - try: - is_equal = x.__eq__(y) - except AttributeError: - is_equal = NotImplemented - - if is_equal == NotImplemented: - # using == smoothes out weird variations exposed by raw __eq__ - return x == y - - return is_equal - - def test_handler(event, context): - # this runs after the transaction has started, which means we - # can make assertions about traces_sampler - try: - traces_sampler.assert_any_call( - DictionaryContaining( - { - "aws_event": DictionaryContaining({ - "httpMethod": "GET", - "path": "/sit/stay/rollover", - "headers": {"Host": "x.io", "X-Forwarded-Proto": "http"}, - }), - "aws_context": ObjectDescribedBy( - type=get_lambda_bootstrap().LambdaContext, - attrs={ - 'function_name': StringContaining("test_"), - 'function_version': '$LATEST', - } - ) - } - ) - ) - except AssertionError: - # catch the error and return it because the error itself will - # get swallowed by the SDK as an "internal exception" - return {"AssertionError raised": True,} - - return {"AssertionError raised": False,} - - - traces_sampler = mock.Mock(return_value=True) - - init_sdk( - traces_sampler=traces_sampler, - ) - """ - ), - b'{"httpMethod": "GET", "path": "/sit/stay/rollover", "headers": {"Host": "x.io", "X-Forwarded-Proto": "http"}}', - ) - - assert response["Payload"]["AssertionError raised"] is False - - -@pytest.mark.xfail( - reason="The limited log output we depend on is being clogged by a new warning" -) -def test_serverless_no_code_instrumentation(run_lambda_function): - """ - Test that ensures that just by adding a lambda layer containing the - python sdk, with no code changes sentry is able to capture errors - """ - - for initial_handler in [ - None, - "test_dir/test_lambda.test_handler", - "test_dir.test_lambda.test_handler", - ]: - print("Testing Initial Handler ", initial_handler) - _, response = run_lambda_function( - dedent( - """ - import sentry_sdk - - def test_handler(event, context): - current_client = sentry_sdk.get_client() - - assert current_client.is_active() - - assert len(current_client.options['integrations']) == 1 - assert isinstance(current_client.options['integrations'][0], - sentry_sdk.integrations.aws_lambda.AwsLambdaIntegration) - - raise Exception("Oh!") - """ - ), - b'{"foo": "bar"}', - layer=True, - initial_handler=initial_handler, - ) - assert response["FunctionError"] == "Unhandled" - assert response["StatusCode"] == 200 - - assert response["Payload"]["errorType"] != "AssertionError" - - assert response["Payload"]["errorType"] == "Exception" - assert response["Payload"]["errorMessage"] == "Oh!" - - assert "sentry_handler" in response["LogResult"][3].decode("utf-8") - - -@pytest.mark.xfail( - reason="The limited log output we depend on is being clogged by a new warning" -) -def test_error_has_new_trace_context_performance_enabled(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - sentry_sdk.capture_message("hi") - raise Exception("Oh!") - """ - ), - payload=b'{"foo": "bar"}', - ) - - (msg_event, error_event, transaction_event) = envelope_items - - assert "trace" in msg_event["contexts"] - assert "trace_id" in msg_event["contexts"]["trace"] - - assert "trace" in error_event["contexts"] - assert "trace_id" in error_event["contexts"]["trace"] - - assert "trace" in transaction_event["contexts"] - assert "trace_id" in transaction_event["contexts"]["trace"] - - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - == transaction_event["contexts"]["trace"]["trace_id"] - ) - - -def test_error_has_new_trace_context_performance_disabled(run_lambda_function): - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=None) # this is the default, just added for clarity - - def test_handler(event, context): - sentry_sdk.capture_message("hi") - raise Exception("Oh!") - """ - ), - payload=b'{"foo": "bar"}', - ) - - (msg_event, error_event) = envelope_items - - assert "trace" in msg_event["contexts"] - assert "trace_id" in msg_event["contexts"]["trace"] - - assert "trace" in error_event["contexts"] - assert "trace_id" in error_event["contexts"]["trace"] - - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - ) - - -@pytest.mark.xfail( - reason="The limited log output we depend on is being clogged by a new warning" -) -def test_error_has_existing_trace_context_performance_enabled(run_lambda_function): - trace_id = "471a43a4192642f0b136d5159a501701" - parent_span_id = "6e8f22c393e68f19" - parent_sampled = 1 - sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) - - # We simulate here AWS Api Gateway's behavior of passing HTTP headers - # as the `headers` dict in the event passed to the Lambda function. - payload = { - "headers": { - "sentry-trace": sentry_trace_header, - } - } - - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - sentry_sdk.capture_message("hi") - raise Exception("Oh!") - """ - ), - payload=json.dumps(payload).encode(), - ) - - (msg_event, error_event, transaction_event) = envelope_items - - assert "trace" in msg_event["contexts"] - assert "trace_id" in msg_event["contexts"]["trace"] - - assert "trace" in error_event["contexts"] - assert "trace_id" in error_event["contexts"]["trace"] - - assert "trace" in transaction_event["contexts"] - assert "trace_id" in transaction_event["contexts"]["trace"] - - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - == transaction_event["contexts"]["trace"]["trace_id"] - == "471a43a4192642f0b136d5159a501701" - ) - - -def test_error_has_existing_trace_context_performance_disabled(run_lambda_function): - trace_id = "471a43a4192642f0b136d5159a501701" - parent_span_id = "6e8f22c393e68f19" - parent_sampled = 1 - sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) - - # We simulate here AWS Api Gateway's behavior of passing HTTP headers - # as the `headers` dict in the event passed to the Lambda function. - payload = { - "headers": { - "sentry-trace": sentry_trace_header, - } - } - - envelope_items, _ = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=None) # this is the default, just added for clarity - - def test_handler(event, context): - sentry_sdk.capture_message("hi") - raise Exception("Oh!") - """ - ), - payload=json.dumps(payload).encode(), - ) - - (msg_event, error_event) = envelope_items - - assert "trace" in msg_event["contexts"] - assert "trace_id" in msg_event["contexts"]["trace"] - - assert "trace" in error_event["contexts"] - assert "trace_id" in error_event["contexts"]["trace"] - - assert ( - msg_event["contexts"]["trace"]["trace_id"] - == error_event["contexts"]["trace"]["trace_id"] - == "471a43a4192642f0b136d5159a501701" - ) - - -def test_basic_with_eventbridge_source(run_lambda_function): - envelope_items, response = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk() - - def test_handler(event, context): - raise Exception("Oh!") - """ - ), - b'[{"topic":"lps-ranges","partition":1,"offset":0,"timestamp":1701268939207,"timestampType":"CREATE_TIME","key":"REDACTED","value":"REDACTED","headers":[],"eventSourceArn":"REDACTED","bootstrapServers":"REDACTED","eventSource":"aws:kafka","eventSourceKey":"lps-ranges-1"}]', - ) - - assert response["FunctionError"] == "Unhandled" - - (event,) = envelope_items - assert event["level"] == "error" - (exception,) = event["exception"]["values"] - assert exception["type"] == "Exception" - assert exception["value"] == "Oh!" - - -def test_span_origin(run_lambda_function): - envelope_items, response = run_lambda_function( - LAMBDA_PRELUDE - + dedent( - """ - init_sdk(traces_sample_rate=1.0) - - def test_handler(event, context): - pass - """ - ), - b'{"foo": "bar"}', - ) - - (event,) = envelope_items - - assert event["contexts"]["trace"]["origin"] == "auto.function.aws_lambda" diff --git a/tests/integrations/aws_lambda/test_aws_lambda.py b/tests/integrations/aws_lambda/test_aws_lambda.py new file mode 100644 index 0000000000..85da7e0b14 --- /dev/null +++ b/tests/integrations/aws_lambda/test_aws_lambda.py @@ -0,0 +1,550 @@ +import boto3 +import docker +import json +import pytest +import subprocess +import tempfile +import time +import yaml + +from unittest import mock + +from aws_cdk import App + +from .utils import LocalLambdaStack, SentryServerForTesting, SAM_PORT + + +DOCKER_NETWORK_NAME = "lambda-test-network" +SAM_TEMPLATE_FILE = "sam.template.yaml" + + +@pytest.fixture(scope="session", autouse=True) +def test_environment(): + print("[test_environment fixture] Setting up AWS Lambda test infrastructure") + + # Create a Docker network + docker_client = docker.from_env() + docker_client.networks.prune() + docker_client.networks.create(DOCKER_NETWORK_NAME, driver="bridge") + + # Start Sentry server + server = SentryServerForTesting() + server.start() + time.sleep(1) # Give it a moment to start up + + # Create local AWS SAM stack + app = App() + stack = LocalLambdaStack(app, "LocalLambdaStack") + + # Write SAM template to file + template = app.synth().get_stack_by_name("LocalLambdaStack").template + with open(SAM_TEMPLATE_FILE, "w") as f: + yaml.dump(template, f) + + # Write SAM debug log to file + debug_log_file = tempfile.gettempdir() + "/sentry_aws_lambda_tests_sam_debug.log" + debug_log = open(debug_log_file, "w") + print("[test_environment fixture] Writing SAM debug log to: %s" % debug_log_file) + + # Start SAM local + process = subprocess.Popen( + [ + "sam", + "local", + "start-lambda", + "--debug", + "--template", + SAM_TEMPLATE_FILE, + "--warm-containers", + "EAGER", + "--docker-network", + DOCKER_NETWORK_NAME, + ], + stdout=debug_log, + stderr=debug_log, + text=True, # This makes stdout/stderr return strings instead of bytes + ) + + try: + # Wait for SAM to be ready + LocalLambdaStack.wait_for_stack() + + def before_test(): + server.clear_envelopes() + + yield { + "stack": stack, + "server": server, + "before_test": before_test, + } + + finally: + print("[test_environment fixture] Tearing down AWS Lambda test infrastructure") + + process.terminate() + process.wait(timeout=5) # Give it time to shut down gracefully + + # Force kill if still running + if process.poll() is None: + process.kill() + + +@pytest.fixture(autouse=True) +def clear_before_test(test_environment): + test_environment["before_test"]() + + +@pytest.fixture +def lambda_client(): + """ + Create a boto3 client configured to use the local AWS SAM instance. + """ + return boto3.client( + "lambda", + endpoint_url=f"http://127.0.0.1:{SAM_PORT}", # noqa: E231 + aws_access_key_id="dummy", + aws_secret_access_key="dummy", + region_name="us-east-1", + ) + + +def test_basic_no_exception(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="BasicOk", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "BasicOk" + assert transaction_event["sdk"]["name"] == "sentry.python.aws_lambda" + assert transaction_event["tags"] == {"aws_region": "us-east-1"} + + assert transaction_event["extra"]["cloudwatch logs"] == { + "log_group": mock.ANY, + "log_stream": mock.ANY, + "url": mock.ANY, + } + assert transaction_event["extra"]["lambda"] == { + "aws_request_id": mock.ANY, + "execution_duration_in_millis": mock.ANY, + "function_name": "BasicOk", + "function_version": "$LATEST", + "invoked_function_arn": "arn:aws:lambda:us-east-1:012345678912:function:BasicOk", + "remaining_time_in_millis": mock.ANY, + } + assert transaction_event["contexts"]["trace"] == { + "op": "function.aws", + "description": mock.ANY, + "span_id": mock.ANY, + "parent_span_id": mock.ANY, + "trace_id": mock.ANY, + "origin": "auto.function.aws_lambda", + "data": mock.ANY, + } + + +def test_basic_exception(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="BasicException", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + # The second envelope we ignore. + # It is the transaction that we test in test_basic_no_exception. + (error_event, _) = envelopes + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + assert error_event["exception"]["values"][0]["value"] == "Oh!" + assert error_event["sdk"]["name"] == "sentry.python.aws_lambda" + + assert error_event["tags"] == {"aws_region": "us-east-1"} + assert error_event["extra"]["cloudwatch logs"] == { + "log_group": mock.ANY, + "log_stream": mock.ANY, + "url": mock.ANY, + } + assert error_event["extra"]["lambda"] == { + "aws_request_id": mock.ANY, + "execution_duration_in_millis": mock.ANY, + "function_name": "BasicException", + "function_version": "$LATEST", + "invoked_function_arn": "arn:aws:lambda:us-east-1:012345678912:function:BasicException", + "remaining_time_in_millis": mock.ANY, + } + assert error_event["contexts"]["trace"] == { + "op": "function.aws", + "description": mock.ANY, + "span_id": mock.ANY, + "parent_span_id": mock.ANY, + "trace_id": mock.ANY, + "origin": "auto.function.aws_lambda", + "data": mock.ANY, + } + + +def test_init_error(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="InitError", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + (error_event, transaction_event) = envelopes + + assert ( + error_event["exception"]["values"][0]["value"] == "name 'func' is not defined" + ) + assert transaction_event["transaction"] == "InitError" + + +def test_timeout_error(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="TimeoutError", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + (error_event,) = envelopes + + assert error_event["level"] == "error" + assert error_event["extra"]["lambda"]["function_name"] == "TimeoutError" + + (exception,) = error_event["exception"]["values"] + assert not exception["mechanism"]["handled"] + assert exception["type"] == "ServerlessTimeoutWarning" + assert exception["value"].startswith( + "WARNING : Function is expected to get timed out. Configured timeout duration =" + ) + assert exception["mechanism"]["type"] == "threading" + + +@pytest.mark.parametrize( + "aws_event, has_request_data, batch_size", + [ + (b"1231", False, 1), + (b"11.21", False, 1), + (b'"Good dog!"', False, 1), + (b"true", False, 1), + ( + b""" + [ + {"good dog": "Maisey"}, + {"good dog": "Charlie"}, + {"good dog": "Cory"}, + {"good dog": "Bodhi"} + ] + """, + False, + 4, + ), + ( + b""" + [ + { + "headers": { + "Host": "x1.io", + "X-Forwarded-Proto": "https" + }, + "httpMethod": "GET", + "path": "/1", + "queryStringParameters": { + "done": "f" + }, + "d": "D1" + }, + { + "headers": { + "Host": "x2.io", + "X-Forwarded-Proto": "http" + }, + "httpMethod": "POST", + "path": "/2", + "queryStringParameters": { + "done": "t" + }, + "d": "D2" + } + ] + """, + True, + 2, + ), + (b"[]", False, 1), + ], + ids=[ + "event as integer", + "event as float", + "event as string", + "event as bool", + "event as list of dicts", + "event as dict", + "event as empty list", + ], +) +def test_non_dict_event( + lambda_client, test_environment, aws_event, has_request_data, batch_size +): + lambda_client.invoke( + FunctionName="BasicException", + Payload=aws_event, + ) + envelopes = test_environment["server"].envelopes + + (error_event, transaction_event) = envelopes + + assert transaction_event["type"] == "transaction" + assert transaction_event["transaction"] == "BasicException" + assert transaction_event["sdk"]["name"] == "sentry.python.aws_lambda" + assert transaction_event["contexts"]["trace"]["status"] == "internal_error" + + assert error_event["level"] == "error" + assert error_event["transaction"] == "BasicException" + assert error_event["sdk"]["name"] == "sentry.python.aws_lambda" + assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + assert error_event["exception"]["values"][0]["value"] == "Oh!" + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "aws_lambda" + + if has_request_data: + request_data = { + "headers": {"Host": "x1.io", "X-Forwarded-Proto": "https"}, + "method": "GET", + "url": "https://x1.io/1", + "query_string": { + "done": "f", + }, + } + else: + request_data = {"url": "awslambda:///BasicException"} + + assert error_event["request"] == request_data + assert transaction_event["request"] == request_data + + if batch_size > 1: + assert error_event["tags"]["batch_size"] == batch_size + assert error_event["tags"]["batch_request"] is True + assert transaction_event["tags"]["batch_size"] == batch_size + assert transaction_event["tags"]["batch_request"] is True + + +def test_request_data(lambda_client, test_environment): + payload = b""" + { + "resource": "/asd", + "path": "/asd", + "httpMethod": "GET", + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https" + }, + "queryStringParameters": { + "bonkers": "true" + }, + "pathParameters": null, + "stageVariables": null, + "requestContext": { + "identity": { + "sourceIp": "213.47.147.207", + "userArn": "42" + } + }, + "body": null, + "isBase64Encoded": false + } + """ + + lambda_client.invoke( + FunctionName="BasicOk", + Payload=payload, + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert transaction_event["request"] == { + "headers": { + "Host": "iwsz2c7uwi.execute-api.us-east-1.amazonaws.com", + "User-Agent": "custom", + "X-Forwarded-Proto": "https", + }, + "method": "GET", + "query_string": {"bonkers": "true"}, + "url": "https://iwsz2c7uwi.execute-api.us-east-1.amazonaws.com/asd", + } + + +def test_trace_continuation(lambda_client, test_environment): + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = 1 + sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + # We simulate here AWS Api Gateway's behavior of passing HTTP headers + # as the `headers` dict in the event passed to the Lambda function. + payload = { + "headers": { + "sentry-trace": sentry_trace_header, + } + } + + lambda_client.invoke( + FunctionName="BasicException", + Payload=json.dumps(payload), + ) + envelopes = test_environment["server"].envelopes + + (error_event, transaction_event) = envelopes + + assert ( + error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"headers": None}, + {"headers": ""}, + {"headers": {}}, + {"headers": []}, # EventBridge sends an empty list + ], + ids=[ + "no headers", + "none headers", + "empty string headers", + "empty dict headers", + "empty list headers", + ], +) +def test_headers(lambda_client, test_environment, payload): + lambda_client.invoke( + FunctionName="BasicException", + Payload=json.dumps(payload), + ) + envelopes = test_environment["server"].envelopes + + (error_event, _) = envelopes + + assert error_event["level"] == "error" + assert error_event["exception"]["values"][0]["type"] == "RuntimeError" + assert error_event["exception"]["values"][0]["value"] == "Oh!" + + +def test_span_origin(lambda_client, test_environment): + lambda_client.invoke( + FunctionName="BasicOk", + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + (transaction_event,) = envelopes + + assert ( + transaction_event["contexts"]["trace"]["origin"] == "auto.function.aws_lambda" + ) + + +def test_traces_sampler_has_correct_sampling_context(lambda_client, test_environment): + """ + Test that aws_event and aws_context are passed in the custom_sampling_context + when using the AWS Lambda integration. + """ + test_payload = {"test_key": "test_value"} + response = lambda_client.invoke( + FunctionName="TracesSampler", + Payload=json.dumps(test_payload), + ) + response_payload = json.loads(response["Payload"].read().decode()) + sampling_context_data = json.loads(response_payload["body"])[ + "sampling_context_data" + ] + assert sampling_context_data.get("aws_event_present") is True + assert sampling_context_data.get("aws_context_present") is True + assert sampling_context_data.get("event_data", {}).get("test_key") == "test_value" + + +@pytest.mark.parametrize( + "lambda_function_name", + ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"], +) +def test_error_has_new_trace_context( + lambda_client, test_environment, lambda_function_name +): + lambda_client.invoke( + FunctionName=lambda_function_name, + Payload=json.dumps({}), + ) + envelopes = test_environment["server"].envelopes + + if lambda_function_name == "RaiseErrorPerformanceEnabled": + (error_event, transaction_event) = envelopes + else: + (error_event,) = envelopes + transaction_event = None + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + + if transaction_event: + assert "trace" in transaction_event["contexts"] + assert "trace_id" in transaction_event["contexts"]["trace"] + assert ( + error_event["contexts"]["trace"]["trace_id"] + == transaction_event["contexts"]["trace"]["trace_id"] + ) + + +@pytest.mark.parametrize( + "lambda_function_name", + ["RaiseErrorPerformanceEnabled", "RaiseErrorPerformanceDisabled"], +) +def test_error_has_existing_trace_context( + lambda_client, test_environment, lambda_function_name +): + trace_id = "471a43a4192642f0b136d5159a501701" + parent_span_id = "6e8f22c393e68f19" + parent_sampled = 1 + sentry_trace_header = "{}-{}-{}".format(trace_id, parent_span_id, parent_sampled) + + # We simulate here AWS Api Gateway's behavior of passing HTTP headers + # as the `headers` dict in the event passed to the Lambda function. + payload = { + "headers": { + "sentry-trace": sentry_trace_header, + } + } + + lambda_client.invoke( + FunctionName=lambda_function_name, + Payload=json.dumps(payload), + ) + envelopes = test_environment["server"].envelopes + + if lambda_function_name == "RaiseErrorPerformanceEnabled": + (error_event, transaction_event) = envelopes + else: + (error_event,) = envelopes + transaction_event = None + + assert "trace" in error_event["contexts"] + assert "trace_id" in error_event["contexts"]["trace"] + assert ( + error_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) + + if transaction_event: + assert "trace" in transaction_event["contexts"] + assert "trace_id" in transaction_event["contexts"]["trace"] + assert ( + transaction_event["contexts"]["trace"]["trace_id"] + == "471a43a4192642f0b136d5159a501701" + ) diff --git a/tests/integrations/aws_lambda/utils.py b/tests/integrations/aws_lambda/utils.py new file mode 100644 index 0000000000..d20c9352e7 --- /dev/null +++ b/tests/integrations/aws_lambda/utils.py @@ -0,0 +1,294 @@ +import gzip +import json +import os +import shutil +import subprocess +import requests +import sys +import time +import threading +import socket +import platform + +from aws_cdk import ( + CfnResource, + Stack, +) +from constructs import Construct +from fastapi import FastAPI, Request +import uvicorn + +from scripts.build_aws_lambda_layer import build_packaged_zip, DIST_PATH + + +LAMBDA_FUNCTION_DIR = "./tests/integrations/aws_lambda/lambda_functions/" +LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR = ( + "./tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/" +) +LAMBDA_FUNCTION_TIMEOUT = 10 +SAM_PORT = 3001 + +PYTHON_VERSION = f"python{sys.version_info.major}.{sys.version_info.minor}" + + +def get_host_ip(): + """ + Returns the IP address of the host we are running on. + """ + if os.environ.get("GITHUB_ACTIONS"): + # Running in GitHub Actions + hostname = socket.gethostname() + host = socket.gethostbyname(hostname) + else: + # Running locally + if platform.system() in ["Darwin", "Windows"]: + # Windows or MacOS + host = "host.docker.internal" + else: + # Linux + hostname = socket.gethostname() + host = socket.gethostbyname(hostname) + + return host + + +def get_project_root(): + """ + Returns the absolute path to the project root directory. + """ + # Start from the current file's directory + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # Navigate up to the project root (4 levels up from tests/integrations/aws_lambda/) + # This is equivalent to the multiple dirname() calls + project_root = os.path.abspath(os.path.join(current_dir, "../../../")) + + return project_root + + +class LocalLambdaStack(Stack): + """ + Uses the AWS CDK to create a local SAM stack containing Lambda functions. + """ + + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + print("[LocalLambdaStack] Creating local SAM Lambda Stack") + super().__init__(scope, construct_id, **kwargs) + + # Override the template synthesis + self.template_options.template_format_version = "2010-09-09" + self.template_options.transforms = ["AWS::Serverless-2016-10-31"] + + print("[LocalLambdaStack] Create Sentry Lambda layer package") + filename = "sentry-sdk-lambda-layer.zip" + build_packaged_zip( + make_dist=True, + out_zip_filename=filename, + ) + + print( + "[LocalLambdaStack] Add Sentry Lambda layer containing the Sentry SDK to the SAM stack" + ) + self.sentry_layer = CfnResource( + self, + "SentryPythonServerlessSDK", + type="AWS::Serverless::LayerVersion", + properties={ + "ContentUri": os.path.join(DIST_PATH, filename), + "CompatibleRuntimes": [ + PYTHON_VERSION, + ], + }, + ) + + dsn = f"http://123@{get_host_ip()}:9999/0" # noqa: E231 + print("[LocalLambdaStack] Using Sentry DSN: %s" % dsn) + + print( + "[LocalLambdaStack] Add all Lambda functions defined in " + "/tests/integrations/aws_lambda/lambda_functions/ to the SAM stack" + ) + lambda_dirs = [ + d + for d in os.listdir(LAMBDA_FUNCTION_DIR) + if os.path.isdir(os.path.join(LAMBDA_FUNCTION_DIR, d)) + ] + for lambda_dir in lambda_dirs: + CfnResource( + self, + lambda_dir, + type="AWS::Serverless::Function", + properties={ + "CodeUri": os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir), + "Handler": "sentry_sdk.integrations.init_serverless_sdk.sentry_lambda_handler", + "Runtime": PYTHON_VERSION, + "Timeout": LAMBDA_FUNCTION_TIMEOUT, + "Layers": [ + {"Ref": self.sentry_layer.logical_id} + ], # Add layer containing the Sentry SDK to function. + "Environment": { + "Variables": { + "SENTRY_DSN": dsn, + "SENTRY_INITIAL_HANDLER": "index.handler", + "SENTRY_TRACES_SAMPLE_RATE": "1.0", + } + }, + }, + ) + print( + "[LocalLambdaStack] - Created Lambda function: %s (%s)" + % ( + lambda_dir, + os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir), + ) + ) + + print( + "[LocalLambdaStack] Add all Lambda functions defined in " + "/tests/integrations/aws_lambda/lambda_functions_with_embedded_sdk/ to the SAM stack" + ) + lambda_dirs = [ + d + for d in os.listdir(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR) + if os.path.isdir(os.path.join(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, d)) + ] + for lambda_dir in lambda_dirs: + # Copy the Sentry SDK into the function directory + sdk_path = os.path.join( + LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir, "sentry_sdk" + ) + if not os.path.exists(sdk_path): + # Find the Sentry SDK in the current environment + import sentry_sdk as sdk_module + + sdk_source = os.path.dirname(sdk_module.__file__) + shutil.copytree(sdk_source, sdk_path) + + # Install the requirements of Sentry SDK into the function directory + requirements_file = os.path.join( + get_project_root(), "requirements-aws-lambda-layer.txt" + ) + + # Install the package using pip + subprocess.check_call( + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "--target", + os.path.join(LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir), + "-r", + requirements_file, + ] + ) + + CfnResource( + self, + lambda_dir, + type="AWS::Serverless::Function", + properties={ + "CodeUri": os.path.join( + LAMBDA_FUNCTION_WITH_EMBEDDED_SDK_DIR, lambda_dir + ), + "Handler": "index.handler", + "Runtime": PYTHON_VERSION, + "Timeout": LAMBDA_FUNCTION_TIMEOUT, + "Environment": { + "Variables": { + "SENTRY_DSN": dsn, + } + }, + }, + ) + print( + "[LocalLambdaStack] - Created Lambda function: %s (%s)" + % ( + lambda_dir, + os.path.join(LAMBDA_FUNCTION_DIR, lambda_dir), + ) + ) + + @classmethod + def wait_for_stack(cls, timeout=60, port=SAM_PORT): + """ + Wait for SAM to be ready, with timeout. + """ + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise TimeoutError( + "AWS SAM failed to start within %s seconds. (Maybe Docker is not running?)" + % timeout + ) + + try: + # Try to connect to SAM + response = requests.get(f"http://127.0.0.1:{port}/") # noqa: E231 + if response.status_code == 200 or response.status_code == 404: + return + + except requests.exceptions.ConnectionError: + time.sleep(1) + continue + + +class SentryServerForTesting: + """ + A simple Sentry.io style server that accepts envelopes and stores them in a list. + """ + + def __init__(self, host="0.0.0.0", port=9999, log_level="warning"): + self.envelopes = [] + self.host = host + self.port = port + self.log_level = log_level + self.app = FastAPI() + + @self.app.post("/api/0/envelope/") + async def envelope(request: Request): + print("[SentryServerForTesting] Received envelope") + try: + raw_body = await request.body() + except Exception: + return {"status": "no body received"} + + try: + body = gzip.decompress(raw_body).decode("utf-8") + except Exception: + # If decompression fails, assume it's plain text + body = raw_body.decode("utf-8") + + lines = body.split("\n") + + current_line = 1 # line 0 is envelope header + while current_line < len(lines): + # skip empty lines + if not lines[current_line].strip(): + current_line += 1 + continue + + # skip envelope item header + current_line += 1 + + # add envelope item to store + envelope_item = lines[current_line] + if envelope_item.strip(): + self.envelopes.append(json.loads(envelope_item)) + + return {"status": "ok"} + + def run_server(self): + uvicorn.run(self.app, host=self.host, port=self.port, log_level=self.log_level) + + def start(self): + print( + "[SentryServerForTesting] Starting server on %s:%s" % (self.host, self.port) + ) + server_thread = threading.Thread(target=self.run_server, daemon=True) + server_thread.start() + + def clear_envelopes(self): + print("[SentryServerForTesting] Clearing envelopes") + self.envelopes = [] diff --git a/tox.ini b/tox.ini index f176c70f1a..932ef256ab 100644 --- a/tox.ini +++ b/tox.ini @@ -57,10 +57,7 @@ envlist = {py3.8,py3.11,py3.12}-asyncpg-latest # AWS Lambda - # The aws_lambda tests deploy to the real AWS and have their own - # matrix of Python versions to run the test lambda function in. - # see `lambda_runtime` fixture in tests/integrations/aws_lambda.py - {py3.9}-aws_lambda + {py3.8,py3.9,py3.11,py3.13}-aws_lambda # Beam {py3.7}-beam-v{2.12} @@ -367,7 +364,12 @@ deps = asyncpg: pytest-asyncio # AWS Lambda + aws_lambda: aws-cdk-lib + aws_lambda: aws-sam-cli aws_lambda: boto3 + aws_lambda: fastapi + aws_lambda: requests + aws_lambda: uvicorn # Beam beam-v2.12: apache-beam~=2.12.0 @@ -803,8 +805,6 @@ setenv = socket: TESTPATH=tests/integrations/socket passenv = - SENTRY_PYTHON_TEST_AWS_ACCESS_KEY_ID - SENTRY_PYTHON_TEST_AWS_SECRET_ACCESS_KEY SENTRY_PYTHON_TEST_POSTGRES_HOST SENTRY_PYTHON_TEST_POSTGRES_USER SENTRY_PYTHON_TEST_POSTGRES_PASSWORD