diff --git a/.ci/.matrix_exclude.yml b/.ci/.matrix_exclude.yml index eaf0fa82b..8df06914d 100644 --- a/.ci/.matrix_exclude.yml +++ b/.ci/.matrix_exclude.yml @@ -9,34 +9,60 @@ exclude: FRAMEWORK: django-4.0 - VERSION: python-3.7 FRAMEWORK: django-4.0 + # Django 4.2 requires Python 3.8+ + - VERSION: python-3.6 + FRAMEWORK: django-4.2 + - VERSION: python-3.7 + FRAMEWORK: django-4.2 + # Django 5.0 requires Python 3.10+ + - VERSION: python-3.6 + FRAMEWORK: django-5.0 + - VERSION: python-3.7 + FRAMEWORK: django-5.0 + - VERSION: python-3.8 + FRAMEWORK: django-5.0 + - VERSION: python-3.9 + FRAMEWORK: django-5.0 - VERSION: pypy-3 # current pypy-3 is compatible with Python 3.7 FRAMEWORK: celery-5-django-4 - VERSION: python-3.6 FRAMEWORK: celery-5-django-4 - VERSION: python-3.7 FRAMEWORK: celery-5-django-4 + - VERSION: python-3.6 + FRAMEWORK: celery-5-django-5 + - VERSION: python-3.7 + FRAMEWORK: celery-5-django-5 + - VERSION: python-3.8 + FRAMEWORK: celery-5-django-5 + - VERSION: python-3.9 + FRAMEWORK: celery-5-django-5 # Flask - VERSION: pypy-3 FRAMEWORK: flask-0.11 # see https://github.com/pallets/flask/commit/6e46d0cd, 0.11.2 was never released + - VERSION: python-3.6 + FRAMEWORK: flask-2.1 + - VERSION: python-3.6 + FRAMEWORK: flask-2.2 + - VERSION: python-3.6 + FRAMEWORK: flask-2.3 + - VERSION: python-3.6 + FRAMEWORK: flask-3.0 + - VERSION: python-3.7 + FRAMEWORK: flask-2.3 + - VERSION: python-3.7 + FRAMEWORK: flask-3.0 # Python 3.10 removed a bunch of classes from collections, now in collections.abc - VERSION: python-3.10 FRAMEWORK: django-1.11 - VERSION: python-3.10 FRAMEWORK: django-2.0 - - VERSION: python-3.10 - FRAMEWORK: celery-4-django-2.0 - - VERSION: python-3.10 - FRAMEWORK: celery-4-django-1.11 - - VERSION: python-3.11 # cannot import name 'formatargspec' from 'inspect' - FRAMEWORK: celery-4-flask-1.0 - VERSION: python-3.11 # https://github.com/celery/billiard/issues/377 FRAMEWORK: celery-5-flask-2 - VERSION: python-3.11 # https://github.com/celery/billiard/issues/377 FRAMEWORK: celery-5-django-3 - VERSION: python-3.11 # https://github.com/celery/billiard/issues/377 FRAMEWORK: celery-5-django-4 - - VERSION: python-3.12 # cannot import name 'formatargspec' from 'inspect' - FRAMEWORK: celery-4-flask-1.0 - VERSION: python-3.12 # https://github.com/celery/billiard/issues/377 FRAMEWORK: celery-5-flask-2 - VERSION: python-3.12 # https://github.com/celery/billiard/issues/377 @@ -59,10 +85,6 @@ exclude: FRAMEWORK: django-2.0 - VERSION: python-3.11 FRAMEWORK: django-2.1 - - VERSION: python-3.11 - FRAMEWORK: celery-4-django-2.0 - - VERSION: python-3.11 - FRAMEWORK: celery-4-django-1.11 - VERSION: python-3.11 FRAMEWORK: graphene-2 - VERSION: python-3.11 @@ -79,10 +101,6 @@ exclude: FRAMEWORK: django-2.0 - VERSION: python-3.12 FRAMEWORK: django-2.1 - - VERSION: python-3.12 - FRAMEWORK: celery-4-django-2.0 - - VERSION: python-3.12 - FRAMEWORK: celery-4-django-1.11 - VERSION: python-3.12 FRAMEWORK: graphene-2 - VERSION: python-3.12 @@ -184,8 +202,12 @@ exclude: # asyncpg - VERSION: pypy-3 FRAMEWORK: asyncpg-newest + - VERSION: pypy-3 + FRAMEWORK: asyncpg-0.28 - VERSION: python-3.6 FRAMEWORK: asyncpg-newest + - VERSION: python-3.6 + FRAMEWORK: asyncpg-0.28 # sanic - VERSION: pypy-3 FRAMEWORK: sanic-newest @@ -256,26 +278,10 @@ exclude: FRAMEWORK: flask-1.1 - VERSION: python-3.7 FRAMEWORK: jinja2-2 - - VERSION: python-3.7 - FRAMEWORK: celery-4-flask-1.0 # TODO py3.12 - - VERSION: python-3.12 - FRAMEWORK: pymssql-newest # no wheels available yet - - VERSION: python-3.12 - FRAMEWORK: aiohttp-newest # no wheels available yet - - VERSION: python-3.12 - FRAMEWORK: elasticsearch-7 # relies on aiohttp - - VERSION: python-3.12 - FRAMEWORK: elasticsearch-8 # relies on aiohttp - - VERSION: python-3.12 - FRAMEWORK: aiobotocore-newest # relies on aiohttp - VERSION: python-3.12 FRAMEWORK: sanic-20.12 # no wheels available yet - - VERSION: python-3.12 - FRAMEWORK: sanic-newest # no wheels available yet - VERSION: python-3.12 FRAMEWORK: kafka-python-newest # https://github.com/dpkp/kafka-python/pull/2376 - - VERSION: python-3.12 - FRAMEWORK: pyodbc-newest # error on wheel - VERSION: python-3.12 FRAMEWORK: cassandra-newest # c extension issue diff --git a/.ci/.matrix_framework.yml b/.ci/.matrix_framework.yml index 6bf64ab44..679064a72 100644 --- a/.ci/.matrix_framework.yml +++ b/.ci/.matrix_framework.yml @@ -3,21 +3,20 @@ FRAMEWORK: - none - django-1.11 - - django-2.0 - - django-3.1 - django-3.2 - django-4.0 + - django-4.2 + - django-5.0 - flask-0.12 - - flask-1.1 - - flask-2.0 + - flask-2.3 + - flask-3.0 - jinja2-3 - opentelemetry-newest - opentracing-newest - twisted-newest - - celery-4-flask-1.0 - - celery-4-django-2.0 - celery-5-flask-2 - celery-5-django-4 + - celery-5-django-5 - requests-newest - boto3-newest - pymongo-newest @@ -44,6 +43,8 @@ FRAMEWORK: - aiopg-newest - asyncpg-newest - tornado-newest + # this has a dependency on requests, run it to catch update issues before merging. Drop after baseline > 0.21.0 + - starlette-0.14 - starlette-newest - pymemcache-newest - graphene-2 diff --git a/.ci/.matrix_framework_full.yml b/.ci/.matrix_framework_full.yml index 7b1ee213e..d2482d9ff 100644 --- a/.ci/.matrix_framework_full.yml +++ b/.ci/.matrix_framework_full.yml @@ -10,6 +10,8 @@ FRAMEWORK: - django-3.1 - django-3.2 - django-4.0 + - django-4.2 + - django-5.0 # - django-master - flask-0.10 - flask-0.11 @@ -17,14 +19,16 @@ FRAMEWORK: - flask-1.0 - flask-1.1 - flask-2.0 + - flask-2.1 + - flask-2.2 + - flask-2.3 + - flask-3.0 - jinja2-2 - jinja2-3 - - celery-4-flask-1.0 - - celery-4-django-1.11 - - celery-4-django-2.0 - celery-5-flask-2 - celery-5-django-3 - celery-5-django-4 + - celery-5-django-5 - opentelemetry-newest - opentracing-newest - opentracing-2.0 diff --git a/.ci/create-arn-table.sh b/.ci/create-arn-table.sh index 3105822ea..a03ead4c6 100755 --- a/.ci/create-arn-table.sh +++ b/.ci/create-arn-table.sh @@ -10,7 +10,8 @@ AWS_FOLDER=${AWS_FOLDER?:No aws folder provided} ARN_FILE=".arn-file.md" { - echo "### Elastic APM Python agent layer ARNs" + echo "
" + echo "Elastic APM Python agent layer ARNs" echo '' echo '|Region|ARN|' echo '|------|---|' @@ -22,4 +23,8 @@ for f in $(ls "${AWS_FOLDER}"); do echo "|${f}|${LAYER_VERSION_ARN}|" >> "${ARN_FILE}" done -echo '' >> "${ARN_FILE}" +{ + echo '' + echo '
' + echo '' +} >> "${ARN_FILE}" diff --git a/.ci/snapshoty.yml b/.ci/snapshoty.yml deleted file mode 100644 index ccebc3426..000000000 --- a/.ci/snapshoty.yml +++ /dev/null @@ -1,36 +0,0 @@ ---- - -# Version of configuration to use -version: '1.0' - -# You can define a Google Cloud Account to use -account: - # Project id of the service account - project: '${GCS_PROJECT}' - # Private key id of the service account - private_key_id: '${GCS_PRIVATE_KEY_ID}' - # Private key of the service account - private_key: '${GCS_PRIVATE_KEY}' - # Email of the service account - client_email: '${GCS_CLIENT_EMAIL}' - # URI token - token_uri: 'https://oauth2.googleapis.com/token' - -# List of artifacts -artifacts: - # Path to use for artifacts discovery - - path: './dist' - # Files pattern to match - files_pattern: 'elastic_apm-(?P\d+\.\d+\.\d+)-(.*)\.whl' - # File layout on GCS bucket - output_pattern: '{project}/{github_branch_name}/elastic-apm-python-{app_version}-{github_sha_short}.whl' - # List of metadata processors to use. - metadata: - # Define static custom metadata - - name: 'custom' - data: - project: 'apm-agent-python' - # Add git metadata - - name: 'git' - # Add github_actions metadata - - name: 'github_actions' diff --git a/.ci/updatecli.d/update-gherkin-specs.yml b/.ci/updatecli.d/update-gherkin-specs.yml deleted file mode 100644 index 8deb269fc..000000000 --- a/.ci/updatecli.d/update-gherkin-specs.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: update-gherkin-specs -pipelineid: update-gherkin-specs -title: synchronize gherkin specs - -scms: - default: - kind: github - spec: - user: '{{ requiredEnv "GIT_USER" }}' - email: '{{ requiredEnv "GIT_EMAIL" }}' - owner: elastic - repository: apm-agent-python - token: '{{ requiredEnv "GITHUB_TOKEN" }}' - username: '{{ requiredEnv "GIT_USER" }}' - branch: main - -sources: - sha: - kind: file - spec: - file: 'https://github.com/elastic/apm/commit/main.patch' - matchpattern: "^From\\s([0-9a-f]{40})\\s" - transformers: - - findsubmatch: - pattern: "[0-9a-f]{40}" - - api_key.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/api_key.feature - azure_app_service_metadata.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/azure_app_service_metadata.feature - azure_functions_metadata.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/azure_functions_metadata.feature - otel_bridge.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/otel_bridge.feature - outcome.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/outcome.feature - user_agent.feature: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/gherkin-specs/user_agent.feature - -actions: - pr: - kind: "github/pullrequest" - scmid: default - title: '[Automation] Update Gherkin specs' - spec: - automerge: false - draft: false - labels: - - "automation" - description: |- - ### What - APM agent Gherkin specs automatic sync - ### Why - *Changeset* - * https://github.com/elastic/apm/commit/{{ source "sha" }} - -targets: - api_key.feature: - name: api_key.feature - scmid: default - sourceid: api_key.feature - kind: file - spec: - file: tests/bdd/features/api_key.feature - forcecreate: true - azure_app_service_metadata.feature: - name: azure_app_service_metadata.feature - scmid: default - sourceid: azure_app_service_metadata.feature - kind: file - spec: - file: tests/bdd/features/azure_app_service_metadata.feature - forcecreate: true - azure_functions_metadata.feature: - name: azure_functions_metadata.feature - scmid: default - sourceid: azure_functions_metadata.feature - kind: file - spec: - file: tests/bdd/features/azure_functions_metadata.feature - forcecreate: true - otel_bridge.feature: - name: otel_bridge.feature - scmid: default - sourceid: otel_bridge.feature - kind: file - spec: - file: tests/bdd/features/otel_bridge.feature - forcecreate: true - outcome.feature: - name: outcome.feature - scmid: default - sourceid: outcome.feature - kind: file - spec: - file: tests/bdd/features/outcome.feature - forcecreate: true - user_agent.feature: - name: user_agent.feature - scmid: default - sourceid: user_agent.feature - kind: file - spec: - file: tests/bdd/features/user_agent.feature - forcecreate: true diff --git a/.ci/updatecli.d/update-json-specs.yml b/.ci/updatecli.d/update-json-specs.yml deleted file mode 100644 index 13d25c834..000000000 --- a/.ci/updatecli.d/update-json-specs.yml +++ /dev/null @@ -1,122 +0,0 @@ -name: update-json-specs -pipelineid: update-json-specs -title: synchronize json specs - -scms: - default: - kind: github - spec: - user: '{{ requiredEnv "GIT_USER" }}' - email: '{{ requiredEnv "GIT_EMAIL" }}' - owner: elastic - repository: apm-agent-python - token: '{{ requiredEnv "GITHUB_TOKEN" }}' - username: '{{ requiredEnv "GIT_USER" }}' - branch: main - -sources: - sha: - kind: file - spec: - file: 'https://github.com/elastic/apm/commit/main.patch' - matchpattern: "^From\\s([0-9a-f]{40})\\s" - transformers: - - findsubmatch: - pattern: "[0-9a-f]{40}" - - container_metadata_discovery.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/container_metadata_discovery.json - service_resource_inference.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/service_resource_inference.json - span_types.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/span_types.json - sql_signature_examples.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/sql_signature_examples.json - sql_token_examples.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/sql_token_examples.json - w3c_distributed_tracing.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/w3c_distributed_tracing.json - wildcard_matcher_tests.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm/main/tests/agents/json-specs/wildcard_matcher_tests.json - -actions: - pr: - kind: "github/pullrequest" - scmid: default - title: '[Automation] Update JSON specs' - spec: - automerge: false - draft: false - labels: - - "automation" - description: |- - ### What - APM agent specs automatic sync - ### Why - *Changeset* - * https://github.com/elastic/apm/commit/{{ source "sha" }} - -targets: - container_metadata_discovery.json: - name: container_metadata_discovery.json - scmid: default - sourceid: container_metadata_discovery.json - kind: file - spec: - file: tests/upstream/json-specs/container_metadata_discovery.json - service_resource_inference.json: - name: service_resource_inference.json - scmid: default - sourceid: service_resource_inference.json - kind: file - spec: - file: tests/upstream/json-specs/service_resource_inference.json - span_types.json: - name: span_types.json - scmid: default - sourceid: span_types.json - kind: file - spec: - file: tests/upstream/json-specs/span_types.json - sql_signature_examples.json: - name: sql_signature_examples.json - scmid: default - sourceid: sql_signature_examples.json - kind: file - spec: - file: tests/upstream/json-specs/sql_signature_examples.json - sql_token_examples.json: - name: sql_token_examples.json - scmid: default - sourceid: sql_token_examples.json - kind: file - spec: - file: tests/upstream/json-specs/sql_token_examples.json - w3c_distributed_tracing.json: - name: w3c_distributed_tracing.json - scmid: default - sourceid: w3c_distributed_tracing.json - kind: file - spec: - file: tests/upstream/json-specs/w3c_distributed_tracing.json - wildcard_matcher_tests.json: - name: wildcard_matcher_tests.json - scmid: default - sourceid: wildcard_matcher_tests.json - kind: file - spec: - file: tests/upstream/json-specs/wildcard_matcher_tests.json diff --git a/.ci/updatecli.d/update-specs.yml b/.ci/updatecli.d/update-specs.yml deleted file mode 100644 index ab3bd34c7..000000000 --- a/.ci/updatecli.d/update-specs.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: update-specs -pipelineid: update-schema-specs -title: synchronize schema specs - -scms: - default: - kind: github - spec: - user: '{{ requiredEnv "GIT_USER" }}' - email: '{{ requiredEnv "GIT_EMAIL" }}' - owner: elastic - repository: apm-agent-python - token: '{{ requiredEnv "GITHUB_TOKEN" }}' - username: '{{ requiredEnv "GIT_USER" }}' - branch: main - -sources: - sha: - kind: file - spec: - file: 'https://github.com/elastic/apm-data/commit/main.patch' - matchpattern: "^From\\s([0-9a-f]{40})\\s" - transformers: - - findsubmatch: - pattern: "[0-9a-f]{40}" - error.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm-data/main/input/elasticapm/docs/spec/v2/error.json - metadata.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm-data/main/input/elasticapm/docs/spec/v2/metadata.json - metricset.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm-data/main/input/elasticapm/docs/spec/v2/metricset.json - span.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm-data/main/input/elasticapm/docs/spec/v2/span.json - transaction.json: - kind: file - spec: - file: https://raw.githubusercontent.com/elastic/apm-data/main/input/elasticapm/docs/spec/v2/transaction.json - -actions: - pr: - kind: "github/pullrequest" - scmid: default - title: '[Automation] Update JSON schema specs' - spec: - automerge: false - draft: false - labels: - - "automation" - description: |- - ### What - APM agent json schema automatic sync - ### Why - *Changeset* - * https://github.com/elastic/apm-data/commit/{{ source "sha" }} - -targets: - error.json: - name: error.json - scmid: default - sourceid: error.json - kind: file - spec: - file: tests/upstream/json-specs/error.json - forcecreate: true - metadata.json: - name: metadata.json - scmid: default - sourceid: metadata.json - kind: file - spec: - file: tests/upstream/json-specs/metadata.json - forcecreate: true - metricset.json: - name: metricset.json - scmid: default - sourceid: metricset.json - kind: file - spec: - file: tests/upstream/json-specs/metricset.json - forcecreate: true - span.json: - name: span.json - scmid: default - sourceid: span.json - kind: file - spec: - file: tests/upstream/json-specs/span.json - forcecreate: true - transaction.json: - name: transaction.json - scmid: default - sourceid: transaction.json - kind: file - spec: - file: tests/upstream/json-specs/transaction.json - forcecreate: true diff --git a/.ci/updatecli/values.d/apm-data-spec.yml b/.ci/updatecli/values.d/apm-data-spec.yml new file mode 100644 index 000000000..4bf89f633 --- /dev/null +++ b/.ci/updatecli/values.d/apm-data-spec.yml @@ -0,0 +1 @@ +apm_schema_specs_path: tests/upstream/json-specs diff --git a/.ci/updatecli/values.d/apm-gherkin.yml b/.ci/updatecli/values.d/apm-gherkin.yml new file mode 100644 index 000000000..7234fe8c8 --- /dev/null +++ b/.ci/updatecli/values.d/apm-gherkin.yml @@ -0,0 +1 @@ +apm_gherkin_specs_path: tests/bdd/features \ No newline at end of file diff --git a/.ci/updatecli/values.d/apm-json-specs.yml b/.ci/updatecli/values.d/apm-json-specs.yml new file mode 100644 index 000000000..c527210e4 --- /dev/null +++ b/.ci/updatecli/values.d/apm-json-specs.yml @@ -0,0 +1 @@ +apm_json_specs_path: tests/upstream/json-specs diff --git a/.ci/updatecli/values.d/scm.yml b/.ci/updatecli/values.d/scm.yml new file mode 100644 index 000000000..78f9e4bc0 --- /dev/null +++ b/.ci/updatecli/values.d/scm.yml @@ -0,0 +1,7 @@ +scm: + enabled: true + owner: elastic + repository: apm-agent-python + branch: main + +signedcommit: true \ No newline at end of file diff --git a/.flake8 b/.flake8 index f629a9d29..7be07fbeb 100644 --- a/.flake8 +++ b/.flake8 @@ -1,6 +1,5 @@ [flake8] exclude= - elasticapm/utils/wrapt/**, build/**, src/**, tests/**, diff --git a/.github/actions/build-distribution/action.yml b/.github/actions/build-distribution/action.yml new file mode 100644 index 000000000..05c32eeb8 --- /dev/null +++ b/.github/actions/build-distribution/action.yml @@ -0,0 +1,21 @@ +--- + +name: common build distribution tasks +description: Run the build distribution + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Build lambda layer zip + run: ./dev-utils/make-distribution.sh + shell: bash + + - uses: actions/upload-artifact@v3 + with: + name: build-distribution + path: ./build/ + if-no-files-found: error diff --git a/.github/actions/packages/action.yml b/.github/actions/packages/action.yml new file mode 100644 index 000000000..871f49c32 --- /dev/null +++ b/.github/actions/packages/action.yml @@ -0,0 +1,27 @@ +--- + +name: common package tasks +description: Run the packages + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Override the version if there is no tag release. + run: | + if [[ "${GITHUB_REF}" != refs/tags/* ]]; then + echo "ELASTIC_CI_POST_VERSION=${{ github.run_id }}" >> "${GITHUB_ENV}" + fi + shell: bash + - name: Build packages + run: ./dev-utils/make-packages.sh + shell: bash + - name: Upload Packages + uses: actions/upload-artifact@v4 + with: + name: packages + path: | + dist/*.whl + dist/*tar.gz diff --git a/.github/community-label.yml b/.github/community-label.yml deleted file mode 100644 index 8872df2d5..000000000 --- a/.github/community-label.yml +++ /dev/null @@ -1,5 +0,0 @@ -# add 'community' label to all new issues and PRs created by the community -community: - - '.*' -triage: - - '.*' \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eb8155b22..afb941790 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,8 +8,51 @@ updates: # Check for updates once a week schedule: interval: "weekly" + day: "sunday" + time: "22:00" reviewers: - "elastic/apm-agent-python" ignore: - dependency-name: "urllib3" # ignore until lambda runtimes use OpenSSL 1.1.1+ versions: [">=2.0.0"] + + # GitHub actions + - package-ecosystem: "github-actions" + directory: "/" + reviewers: + - "elastic/observablt-ci" + schedule: + interval: "weekly" + day: "sunday" + time: "22:00" + groups: + github-actions: + patterns: + - "*" + + # GitHub composite actions + - package-ecosystem: "github-actions" + directory: "/.github/actions/packages" + reviewers: + - "elastic/observablt-ci" + schedule: + interval: "weekly" + day: "sunday" + time: "22:00" + groups: + github-actions: + patterns: + - "*" + + - package-ecosystem: "github-actions" + directory: "/.github/actions/build-distribution" + reviewers: + - "elastic/observablt-ci" + schedule: + interval: "weekly" + day: "sunday" + time: "22:00" + groups: + github-actions: + patterns: + - "*" diff --git a/.github/labeler-config.yml b/.github/labeler-config.yml deleted file mode 100644 index a1e4dbc29..000000000 --- a/.github/labeler-config.yml +++ /dev/null @@ -1,3 +0,0 @@ -# add 'agent-python' label to all new issues -agent-python: - - '.*' diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 3cdfe70f0..5e2641541 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -39,6 +39,7 @@ Once a PR has been opened then there are two different ways you can trigger buil 1. Commit based 1. UI based, any Elasticians can force a build through the GitHub UI +1. PR review comment-based, any Elastic employees can force a full matrix test run through a PR review comment with the following syntax: `/test matrix`. #### Branches @@ -51,4 +52,4 @@ The tag release follows the naming convention: `v...`, wher ### OpenTelemetry -There is a GitHub workflow in charge to populate what the workflow run in terms of jobs and steps. Those details can be seen in [here](https://ela.st/oblt-ci-cd-stats) (**NOTE**: only available for Elasticians). +Every workflow and its logs are exported to OpenTelemetry traces/logs/metrics. Those details can be seen [here](https://ela.st/oblt-ci-cd-stats) (**NOTE**: only available for Elasticians). diff --git a/.github/workflows/addToProject.yml b/.github/workflows/addToProject.yml deleted file mode 100644 index 0a3b76924..000000000 --- a/.github/workflows/addToProject.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Auto Assign to Project(s) - -on: - issues: - types: [opened, edited, milestoned] -env: - MY_GITHUB_TOKEN: ${{ secrets.APM_TECH_USER_TOKEN }} - -jobs: - assign_one_project: - runs-on: ubuntu-latest - name: Assign milestoned to Project - steps: - - name: Assign issues with milestones to project - uses: elastic/assign-one-project-github-action@1.2.2 - if: github.event.issue && github.event.issue.milestone - with: - project: 'https://github.com/orgs/elastic/projects/454' - project_id: '5882982' - column_name: 'Planned' diff --git a/.github/workflows/build-distribution.yml b/.github/workflows/build-distribution.yml deleted file mode 100644 index 986632acd..000000000 --- a/.github/workflows/build-distribution.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: build-distribution - -on: - workflow_call: ~ - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Build lambda layer zip - run: ./dev-utils/make-distribution.sh - - uses: actions/upload-artifact@v3 - with: - name: build-distribution - path: ./build/ - if-no-files-found: error diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index df219658c..a14b036c0 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -4,43 +4,30 @@ on: types: [opened] pull_request_target: types: [opened] -env: - MY_GITHUB_TOKEN: ${{ secrets.APM_TECH_USER_TOKEN }} + +# '*: write' permissions for https://docs.github.com/en/rest/issues/labels?apiVersion=2022-11-28#add-labels-to-an-issue +permissions: + contents: read + issues: write + pull-requests: write + jobs: triage: runs-on: ubuntu-latest steps: - name: Add agent-python label - uses: AlexanderWert/issue-labeler@v2.3 + uses: actions-ecosystem/action-add-labels@v1 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - configuration-path: .github/labeler-config.yml - enable-versioned-regex: 0 - - name: Check team membership for user - uses: elastic/get-user-teams-membership@v1.0.4 - id: checkUserMember + labels: agent-python + - id: is_elastic_member + uses: elastic/apm-pipeline-library/.github/actions/is-member-elastic-org@current with: username: ${{ github.actor }} - team: 'apm' - usernamesToExclude: | - apmmachine - dependabot - GITHUB_TOKEN: ${{ secrets.APM_TECH_USER_TOKEN }} - - name: Show team membership - run: | - echo "::debug::isTeamMember: ${{ steps.checkUserMember.outputs.isTeamMember }}" - echo "::debug::isExcluded: ${{ steps.checkUserMember.outputs.isExcluded }}" - - name: Add community and triage lables - if: steps.checkUserMember.outputs.isTeamMember != 'true' && steps.checkUserMember.outputs.isExcluded != 'true' - uses: AlexanderWert/issue-labeler@v2.3 + token: ${{ secrets.APM_TECH_USER_TOKEN }} + - name: Add community and triage labels + if: contains(steps.is_elastic_member.outputs.result, 'false') && github.actor != 'dependabot[bot]' && github.actor != 'apmmachine' + uses: actions-ecosystem/action-add-labels@v1 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" - configuration-path: .github/community-label.yml - enable-versioned-regex: 0 - - name: Assign new internal pull requests to project - uses: elastic/assign-one-project-github-action@1.2.2 - if: (steps.checkUserMember.outputs.isTeamMember == 'true' || steps.checkUserMember.outputs.isExcluded == 'true') && github.event.pull_request - with: - project: 'https://github.com/orgs/elastic/projects/454' - project_id: '5882982' - column_name: 'In Progress' + labels: | + community + triage diff --git a/.github/workflows/matrix-command.yml b/.github/workflows/matrix-command.yml new file mode 100644 index 000000000..f2c32658f --- /dev/null +++ b/.github/workflows/matrix-command.yml @@ -0,0 +1,49 @@ +name: matrix-command + +on: + pull_request_review: + types: + - submitted + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + command-validation: + if: startsWith(github.event.review.body, '/test matrix') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + pull-requests: write + steps: + - name: Is comment allowed? + uses: actions/github-script@v7 + with: + script: | + const actorPermission = (await github.rest.repos.getCollaboratorPermissionLevel({ + ...context.repo, + username: context.actor + })).data.permission + const isPermitted = ['write', 'admin'].includes(actorPermission) + if (!isPermitted) { + const errorMessage = 'Only users with write permission to the repository can run GitHub commands' + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: errorMessage, + }) + core.setFailed(errorMessage) + return + } + + test: + needs: + - command-validation + uses: ./.github/workflows/test.yml + with: + full-matrix: true + ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/microbenchmark.yml b/.github/workflows/microbenchmark.yml index 9af88a6b1..2230d7e41 100644 --- a/.github/workflows/microbenchmark.yml +++ b/.github/workflows/microbenchmark.yml @@ -16,32 +16,16 @@ permissions: jobs: microbenchmark: runs-on: ubuntu-latest - # wait up to 1 hour - timeout-minutes: 60 + timeout-minutes: 5 steps: - - id: buildkite - name: Run buildkite pipeline - uses: elastic/apm-pipeline-library/.github/actions/buildkite@current + - name: Run microbenchmark + uses: elastic/oblt-actions/buildkite/run@v1.5.0 with: - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} - pipeline: apm-agent-microbenchmark - waitFor: true - printBuildLogs: true - buildEnvVars: | + pipeline: "apm-agent-microbenchmark" + token: ${{ secrets.BUILDKITE_TOKEN }} + wait-for: false + env-vars: | script=.ci/bench.sh repo=apm-agent-python sha=${{ github.sha }} BRANCH_NAME=${{ github.ref_name }} - - - if: ${{ failure() }} - uses: elastic/apm-pipeline-library/.github/actions/slack-message@current - with: - url: ${{ secrets.VAULT_ADDR }} - roleId: ${{ secrets.VAULT_ROLE_ID }} - secretId: ${{ secrets.VAULT_SECRET_ID }} - channel: "#apm-agent-python" - message: | - :ghost: [${{ github.repository }}] microbenchmark *${{ github.ref_name }}* failed to run in Buildkite. - Build: (<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|here>) diff --git a/.github/workflows/opentelemetry.yml b/.github/workflows/opentelemetry.yml deleted file mode 100644 index ea858e655..000000000 --- a/.github/workflows/opentelemetry.yml +++ /dev/null @@ -1,24 +0,0 @@ ---- -name: OpenTelemetry Export Trace - -on: - workflow_run: - workflows: - - pre-commit - - test - - test-reporter - - snapshoty - - release - - packages - - updatecli - types: [completed] - -jobs: - otel-export-trace: - runs-on: ubuntu-latest - steps: - - uses: elastic/apm-pipeline-library/.github/actions/opentelemetry@current - with: - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 148110c7f..496107508 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -13,25 +13,12 @@ on: - '**/*.md' - '**/*.asciidoc' +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 - with: - python-version: "3.10" - - name: Install wheel - run: pip install --user wheel - - name: Building universal wheel - run: python setup.py bdist_wheel - - name: Building source distribution - run: python setup.py sdist - - name: Upload Packages - uses: actions/upload-artifact@v3 - with: - name: packages - path: | - dist/*.whl - dist/*tar.gz - + - uses: actions/checkout@v4 + - uses: ./.github/actions/packages diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index c2f7e71fc..926c21be6 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -5,10 +5,13 @@ on: push: branches: [main] +permissions: + contents: read + jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v3 - - uses: pre-commit/action@v3.0.0 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - uses: pre-commit/action@v3.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03a77ce47..a68e508c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,16 +4,32 @@ on: push: tags: - "v*.*.*" + branches: + - main permissions: contents: read jobs: test: - uses: ./.github/workflows/test.yml + uses: ./.github/workflows/test-release.yml + with: + full-matrix: true + enabled: ${{ startsWith(github.ref, 'refs/tags') }} packages: - uses: ./.github/workflows/packages.yml + permissions: + attestations: write + id-token: write + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/packages + - name: generate build provenance + uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3 + with: + subject-path: "${{ github.workspace }}/dist/*" publish-pypi: needs: @@ -24,46 +40,62 @@ jobs: permissions: id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: packages path: dist - - name: Upload - uses: pypa/gh-action-pypi-publish@f5622bde02b04381239da3573277701ceca8f6a0 + - name: Upload pypi.org + if: startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 with: repository-url: https://upload.pypi.org/legacy/ + - name: Upload test.pypi.org + if: ${{ ! startsWith(github.ref, 'refs/tags') }} + uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 + with: + repository-url: https://test.pypi.org/legacy/ build-distribution: - uses: ./.github/workflows/build-distribution.yml + permissions: + attestations: write + id-token: write + contents: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/build-distribution + - name: generate build provenance + uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3 + with: + subject-path: "${{ github.workspace }}/build/dist/elastic-apm-python-lambda-layer.zip" publish-lambda-layers: + permissions: + contents: read + id-token: write needs: - build-distribution runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: hashicorp/vault-action@v2.7.2 - with: - url: ${{ secrets.VAULT_ADDR }} - method: approle - roleId: ${{ secrets.VAULT_ROLE_ID }} - secretId: ${{ secrets.VAULT_SECRET_ID }} - secrets: | - secret/observability-team/ci/service-account/apm-agent-python access_key_id | AWS_ACCESS_KEY_ID ; - secret/observability-team/ci/service-account/apm-agent-python secret_access_key | AWS_SECRET_ACCESS_KEY + - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: name: build-distribution path: ./build + - uses: elastic/oblt-actions/aws/auth@v1 + with: + aws-account-id: "267093732750" - name: Publish lambda layers to AWS + if: startsWith(github.ref, 'refs/tags') run: | # Convert v1.2.3 to ver-1-2-3 VERSION=${GITHUB_REF_NAME/v/ver-} VERSION=${VERSION//./-} ELASTIC_LAYER_NAME="elastic-apm-python-${VERSION}" .ci/publish-aws.sh - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 + if: startsWith(github.ref, 'refs/tags') with: name: arn-file path: ".arn-file.md" @@ -73,50 +105,75 @@ jobs: needs: - build-distribution runs-on: ubuntu-latest + permissions: + attestations: write + id-token: write + contents: write + strategy: + fail-fast: false + matrix: + dockerfile: [ 'Dockerfile', 'Dockerfile.wolfi' ] + env: + DOCKER_IMAGE_NAME: docker.elastic.co/observability/apm-agent-python steps: - - uses: actions/checkout@v3 - - uses: elastic/apm-pipeline-library/.github/actions/docker-login@current - with: - registry: docker.elastic.co - secret: secret/observability-team/ci/docker-registry/prod - url: ${{ secrets.VAULT_ADDR }} - roleId: ${{ secrets.VAULT_ROLE_ID }} - secretId: ${{ secrets.VAULT_SECRET_ID }} + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@aa33708b10e362ff993539393ff100fa93ed6a27 # v3.5.0 + + - name: Log in to the Elastic Container registry + uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ${{ secrets.ELASTIC_DOCKER_REGISTRY }} + username: ${{ secrets.ELASTIC_DOCKER_USERNAME }} + password: ${{ secrets.ELASTIC_DOCKER_PASSWORD }} + - uses: actions/download-artifact@v3 with: name: build-distribution path: ./build - - id: setup-docker - name: Set up docker variables - run: |- - # version without v prefix (e.g. 1.2.3) - echo "tag=${GITHUB_REF_NAME/v/}" >> "${GITHUB_OUTPUT}" - echo "name=docker.elastic.co/observability/apm-agent-python" >> "${GITHUB_OUTPUT}" - - name: Docker build - run: >- - docker build - -t ${{ steps.setup-docker.outputs.name }}:${{ steps.setup-docker.outputs.tag }} - --build-arg AGENT_DIR=./build/dist/package/python - . - - name: Docker retag - run: >- - docker tag - ${{ steps.setup-docker.outputs.name }}:${{ steps.setup-docker.outputs.tag }} - ${{ steps.setup-docker.outputs.name }}:latest - - name: Docker push - run: |- - docker push ${{ steps.setup-docker.outputs.name }}:${{ steps.setup-docker.outputs.tag }} - docker push ${{ steps.setup-docker.outputs.name }}:latest + + - name: Extract metadata (tags, labels) + id: docker-meta + uses: docker/metadata-action@8e5442c4ef9f78752691e2d8f8d19755c6f78e81 # v5.5.1 + with: + images: ${{ env.DOCKER_IMAGE_NAME }} + tags: | + type=raw,value=latest,prefix=test-,enable={{is_default_branch}} + type=semver,pattern={{version}} + flavor: | + suffix=${{ contains(matrix.dockerfile, 'wolfi') && '-wolfi' || '' }} + + - name: Build and push image + id: docker-push + uses: docker/build-push-action@5176d81f87c23d6fc96624dfdbcd9f3830bbe445 # v6.5.0 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + file: ${{ matrix.dockerfile }} + tags: ${{ steps.docker-meta.outputs.tags }} + labels: ${{ steps.docker-meta.outputs.labels }} + build-args: | + AGENT_DIR=./build/dist/package/python + + - name: generate build provenance (containers) + uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3 + with: + subject-name: "${{ env.DOCKER_IMAGE_NAME }}" + subject-digest: ${{ steps.docker-push.outputs.digest }} + push-to-registry: true github-draft: permissions: contents: write needs: - publish-lambda-layers + if: startsWith(github.ref, 'refs/tags') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 with: name: arn-file - name: Create GitHub Draft Release @@ -142,10 +199,9 @@ jobs: uses: elastic/apm-pipeline-library/.github/actions/check-dependent-jobs@current with: needs: ${{ toJSON(needs) }} - - uses: elastic/apm-pipeline-library/.github/actions/notify-build-status@current + - if: startsWith(github.ref, 'refs/tags') + uses: elastic/oblt-actions/slack/notify-result@v1 with: + bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + channel-id: "#apm-agent-python" status: ${{ steps.check.outputs.status }} - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} - slackChannel: "#apm-agent-python" diff --git a/.github/workflows/run-matrix.yml b/.github/workflows/run-matrix.yml index 811f68dd9..d5db311d6 100644 --- a/.github/workflows/run-matrix.yml +++ b/.github/workflows/run-matrix.yml @@ -8,6 +8,9 @@ on: description: Matrix include JSON string type: string +permissions: + contents: read + jobs: docker: name: "docker (version: ${{ matrix.version }}, framework: ${{ matrix.framework }})" @@ -18,7 +21,7 @@ jobs: matrix: include: ${{ fromJSON(inputs.include) }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run tests run: ./tests/scripts/docker/run_tests.sh ${{ matrix.version }} ${{ matrix.framework }} env: diff --git a/.github/workflows/snapshoty.yml b/.github/workflows/snapshoty.yml deleted file mode 100644 index 3f91e2213..000000000 --- a/.github/workflows/snapshoty.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- -# Publish a snapshot. A "snapshot" is a packaging of the latest *unreleased* APM agent, -# published to a known GCS bucket for use in edge demo/test environments. -name: snapshoty - -on: - workflow_run: - workflows: - - test - types: - - completed - branches: - - main - -jobs: - packages: - if: ${{ github.event.workflow_run.conclusion == 'success' }} - uses: ./.github/workflows/packages.yml - upload: - needs: - - packages - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/download-artifact@v3 - with: - name: packages - path: dist - - name: Publish snaphosts - uses: elastic/apm-pipeline-library/.github/actions/snapshoty-simple@current - with: - config: '.ci/snapshoty.yml' - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml new file mode 100644 index 000000000..c873f9eb7 --- /dev/null +++ b/.github/workflows/test-release.yml @@ -0,0 +1,40 @@ +name: test-release + +on: + workflow_call: + inputs: + full-matrix: + description: "Run the full matrix" + required: true + type: boolean + ref: + description: "The git ref of elastic/apm-agent-python to run test workflow from." + required: false + type: string + enabled: + description: "Whether to run the workfow" + required: true + type: boolean + workflow_dispatch: + inputs: + full-matrix: + description: "Run the full matrix" + required: true + type: boolean + enabled: + description: "Whether to run the workfow" + required: true + type: boolean + +jobs: + test: + if: ${{ inputs.enabled }} + uses: ./.github/workflows/test.yml + with: + full-matrix: ${{ inputs.full-matrix }} + + run-if-disabled: + if: ${{ ! inputs.enabled }} + runs-on: ubuntu-latest + steps: + - run: echo "do something to help with the reusable workflows with needs" diff --git a/.github/workflows/test-reporter.yml b/.github/workflows/test-reporter.yml index 4b0b7620d..1060771c5 100644 --- a/.github/workflows/test-reporter.yml +++ b/.github/workflows/test-reporter.yml @@ -8,6 +8,11 @@ on: types: - completed +permissions: + contents: read + actions: read + checks: write + jobs: report: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 363dec4a6..391d67f67 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,16 @@ name: test # The name must be the same as in test-docs.yml on: - workflow_call: ~ + workflow_call: + inputs: + full-matrix: + description: "Run the full matrix" + required: true + type: boolean + ref: + description: "The git ref of elastic/apm-agent-python to run test workflow from." + required: false + type: string pull_request: paths-ignore: - "**/*.md" @@ -14,10 +23,23 @@ on: - "**/*.asciidoc" schedule: - cron: "0 2 * * *" + workflow_dispatch: + inputs: + full-matrix: + description: "Run the full matrix" + required: true + type: boolean + +permissions: + contents: read jobs: build-distribution: - uses: ./.github/workflows/build-distribution.yml + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/build-distribution + create-matrix: runs-on: ubuntu-latest @@ -26,14 +48,16 @@ jobs: data: ${{ steps.split.outputs.data }} chunks: ${{ steps.split.outputs.chunks }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - id: generate uses: elastic/apm-pipeline-library/.github/actions/version-framework@current with: # Use .ci/.matrix_python_full.yml if it's a scheduled workflow, otherwise use .ci/.matrix_python.yml - versionsFile: .ci/.matrix_python${{ github.event_name == 'schedule' && '_full' || '' }}.yml + versionsFile: .ci/.matrix_python${{ (github.event_name == 'schedule' || github.event_name == 'push' || inputs.full-matrix) && '_full' || '' }}.yml # Use .ci/.matrix_framework_full.yml if it's a scheduled workflow, otherwise use .ci/.matrix_framework.yml - frameworksFile: .ci/.matrix_framework${{ github.event_name == 'schedule' && '_full' || '' }}.yml + frameworksFile: .ci/.matrix_framework${{ (github.event_name == 'schedule' || github.event_name == 'push' || inputs.full-matrix) && '_full' || '' }}.yml excludedFile: .ci/.matrix_exclude.yml - name: Split matrix shell: python @@ -107,8 +131,10 @@ jobs: FRAMEWORK: ${{ matrix.framework }} ASYNCIO: ${{ matrix.asyncio }} steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.version }} cache: pip @@ -142,7 +168,17 @@ jobs: - chunks-3 - windows steps: - - run: test $(echo '${{ toJSON(needs) }}' | jq -s 'map(.[].result) | all(.=="success")') = 'true' + - id: check + uses: elastic/apm-pipeline-library/.github/actions/check-dependent-jobs@current + with: + needs: ${{ toJSON(needs) }} + - run: ${{ steps.check.outputs.isSuccess }} + - if: failure() && (github.event_name == 'schedule' || github.event_name == 'push') + uses: elastic/oblt-actions/slack/notify-result@v1 + with: + bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + status: ${{ steps.check.outputs.status }} + channel-id: "#apm-agent-python" coverage: name: Combine & check coverage. @@ -150,9 +186,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref || github.ref }} - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: # Use latest Python, so it understands all syntax. python-version: 3.11 @@ -175,10 +213,10 @@ jobs: python -Im coverage report --fail-under=84 - name: Upload HTML report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: html-coverage-report path: htmlcov - - uses: geekyeggo/delete-artifact@54ab544f12cdb7b71613a16a2b5a37a9ade990af + - uses: geekyeggo/delete-artifact@24928e75e6e6590170563b8ddae9fac674508aa1 with: name: coverage-reports diff --git a/.github/workflows/updatecli.yml b/.github/workflows/updatecli.yml index 2101ec798..c56645f0e 100644 --- a/.github/workflows/updatecli.yml +++ b/.github/workflows/updatecli.yml @@ -9,20 +9,35 @@ permissions: contents: read jobs: - bump: + compose: runs-on: ubuntu-latest + permissions: + contents: read + packages: read steps: - - uses: actions/checkout@v3 - - uses: elastic/apm-pipeline-library/.github/actions/updatecli@current + - uses: actions/checkout@v4 + + - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 with: - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} - pipeline: .ci/updatecli.d + command: --experimental compose diff + env: + GITHUB_TOKEN: ${{ secrets.UPDATECLI_GH_TOKEN }} + + - uses: elastic/oblt-actions/updatecli/run@v1 + with: + command: --experimental compose apply + env: + GITHUB_TOKEN: ${{ secrets.UPDATECLI_GH_TOKEN }} + - if: failure() - uses: elastic/apm-pipeline-library/.github/actions/notify-build-status@current + uses: elastic/oblt-actions/slack/send@v1 with: - vaultUrl: ${{ secrets.VAULT_ADDR }} - vaultRoleId: ${{ secrets.VAULT_ROLE_ID }} - vaultSecretId: ${{ secrets.VAULT_SECRET_ID }} - slackChannel: "#apm-agent-python" + bot-token: ${{ secrets.SLACK_BOT_TOKEN }} + channel-id: "#apm-agent-python" + message: ":traffic_cone: updatecli failed for `${{ github.repository }}@${{ github.ref_name }}`, @robots-ci please look what's going on " diff --git a/.gitignore b/.gitignore index 88e0a400b..12eae962b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.egg *.db *.pid +*.swp .coverage* .DS_Store .idea @@ -18,7 +19,6 @@ pip-log.txt /docs/doctrees /example_project/*.db tests/.schemacache -elasticapm/utils/wrapt/_wrappers*.so coverage .tox .eggs diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index 0745a960a..000000000 --- a/.hound.yml +++ /dev/null @@ -1,5 +0,0 @@ -flake8: - enabled: true - config_file: .flake8 - -fail_on_violations: true diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index fb87b1107..7550c42d6 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -5,7 +5,7 @@ endif::[] //// [[release-notes-x.x.x]] -==== x.x.x - YYYY/MM/DD +==== x.x.x - YYYY-MM-DD [float] ===== Breaking changes @@ -32,6 +32,108 @@ endif::[] [[release-notes-6.x]] === Python Agent version 6.x +[[release-notes-6.23.0]] +==== 6.23.0 - 2024-07-30 + +[float] +===== Features + +* Make published Docker images multi-platform with the addition of linux/arm64 {pull}2080[#2080] + +[float] +===== Bug fixes + +* Fix handling consumer iteration if transaction not sampled in kafka instrumentation {pull}2075[#2075] +* Fix race condition with urllib3 at shutdown {pull}2085[#2085] +* Fix compatibility with setuptools>=72 that removed test command {pull}2090[#2090] + +===== Deprecations + +* Python 3.6 support will be removed in version 7.0.0 of the agent +* The log shipping LoggingHandler will be removed in version 7.0.0 of the agent. +* The log shipping feature in the Flask instrumentation will be removed in version 7.0.0 of the agent. +* The log shipping feature in the Django instrumentation will be removed in version 7.0.0 of the agent. +* The OpenTracing bridge will be removed in version 7.0.0 of the agent. +* Celery 4.0 support is deprecated because it's not installable anymore with a modern pip + +[[release-notes-6.22.3]] +==== 6.22.3 - 2024-06-10 + +[float] +===== Bug fixes + +* Fix outcome in ASGI and Starlette apps on error status codes without an exception {pull}2060[#2060] + +[[release-notes-6.22.2]] +==== 6.22.2 - 2024-05-20 + +[float] +===== Bug fixes + +* Fix CI release workflow {pull}2046[#2046] + +[[release-notes-6.22.1]] +==== 6.22.1 - 2024-05-17 + +[float] +===== Features + +* Relax wrapt dependency to only exclude 1.15.0 {pull}2005[#2005] + +[[release-notes-6.22.0]] +==== 6.22.0 - 2024-04-03 + +[float] +===== Features + +* Add ability to override default JSON serialization {pull}2018[#2018] + +[[release-notes-6.21.4]] +==== 6.21.4 - 2024-03-19 + +[float] +===== Bug fixes + +* Fix urllib3 2.0.1+ crash with many args {pull}2002[#2002] + +[[release-notes-6.21.3]] +==== 6.21.3 - 2024-03-08 + +[float] +===== Bug fixes + +* Fix artifacts download in CI workflows {pull}1996[#1996] + +[[release-notes-6.21.2]] +==== 6.21.2 - 2024-03-07 + +[float] +===== Bug fixes + +* Fix artifacts upload in CI build-distribution workflow {pull}1993[#1993] + +[[release-notes-6.21.1]] +==== 6.21.1 - 2024-03-07 + +[float] +===== Bug fixes + +* Fix CI release workflow {pull}1990[#1990] + +[[release-notes-6.21.0]] +==== 6.21.0 - 2024-03-06 + +[float] +===== Bug fixes + +* Fix starlette middleware setup without client argument {pull}1952[#1952] +* Fix blocking of gRPC stream-to-stream requests {pull}1967[#1967] +* Always take into account body reading time for starlette requests {pull}1970[#1970] +* Make urllib3 transport tests more robust against local env {pull}1969[#1969] +* Clarify starlette integration documentation {pull}1956[#1956] +* Make dbapi2 query scanning for dollar quotes a bit more correct {pull}1976[#1976] +* Normalize headers in AWS Lambda integration on API Gateway v1 requests {pull}1982[#1982] + [[release-notes-6.20.0]] ==== 6.20.0 - 2024-01-10 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f31f6c3c9..687ac5efb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,7 +64,7 @@ Once your changes are ready to submit for review: 1. Submit a pull request - Push your local changes to your forked copy of the repository and [submit a pull request](https://help.github.com/articles/using-pull-requests). + Push your local changes to your forked copy of the repository and [submit a pull request](https://help.github.com/articles/using-pull-requests) to the `main` branch. In the pull request, choose a title which sums up the changes that you have made, and in the body provide more details about what your changes do. @@ -108,7 +108,7 @@ that is a fixture which is defined #### Adding new instrumentations to the matrix build For tests that require external dependencies like databases, or for testing different versions of the same library, -we use a matrix build that leverages Docker and docker-compose. +we use a matrix build that leverages Docker. The setup requires a little bit of boilerplate to get started. In this example, we will create an instrumentation for the "foo" database, by instrumenting its Python driver, `foodriver`. @@ -153,7 +153,7 @@ In this example, we will create an instrumentation for the "foo" database, by in image: foobase:latest You'll also have to add a `DOCKER_DEPS` environment variable to `tests/scripts/envs/foo.sh` which tells the matrix - to spin up the given docker-compose service before running your tests. + to spin up the given Docker compose service before running your tests. You may also need to add things like hostname configuration here. DOCKER_DEPS="foo" @@ -174,6 +174,11 @@ should "Squash and merge". ### Releasing +Releases tags are signed so you need to have a PGP key set up, you can follow Github documentation on [creating a key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) and +on [telling git about it](https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key). Alternatively you can sign with a SSH key, remember you have to upload your key +again even if you want to use the same key you are using for authorization. +Then make sure you have SSO figured out for the key you are using to push to github, see [Github documentation](https://docs.github.com/articles/authenticating-to-a-github-organization-with-saml-single-sign-on/). + If you have commit access, the process is as follows: 1. Update the version in `elasticapm/version.py` according to the scale of the change. (major, minor or patch) @@ -182,13 +187,15 @@ If you have commit access, the process is as follows: 1. For Majors: Add the new major version to `conf.yaml` in the [elastic/docs](https://github.com/elastic/docs) repo. 1. Commit changes with message `update CHANGELOG and bump version to X.Y.Z` where `X.Y.Z` is the version in `elasticapm/version.py` -1. Open a PR against `main` with these changes +1. Open a PR against `main` with these changes leaving the body empty 1. Once the PR is merged, fetch and checkout `upstream/main` 1. Tag the commit with `git tag -s vX.Y.Z`, for example `git tag -s v1.2.3`. Copy the changelog for the release to the tag message, removing any leading `#`. -1. Reset the current major branch (`1.x`, `2.x` etc) to point to the current main, e.g. `git branch -f 1.x main` 1. Push tag upstream with `git push upstream --tags` (and optionally to your own fork as well) -1. Update major branch, e.g. `1.x` on upstream with `git push upstream 1.x` +1. Open a PR from `main` to the major branch, e.g. `1.x` to update it. In order to keep history create a + branch from the `main` branch, rebase it on top of the major branch to drop duplicated commits and then + merge with the `rebase` strategy. It is crucial that `main` and the major branch have the same content. 1. After tests pass, Github Actions will automatically build and push the new release to PyPI. 1. Edit and publish the [draft Github release](https://github.com/elastic/apm-agent-python/releases) - created by Github Actions. Copy the changelog into the body of the release. + created by Github Actions. Substitute the generated changelog with one hand written into the body of the + release. diff --git a/Dockerfile.wolfi b/Dockerfile.wolfi new file mode 100644 index 000000000..1ed923ce5 --- /dev/null +++ b/Dockerfile.wolfi @@ -0,0 +1,3 @@ +FROM docker.elastic.co/wolfi/chainguard-base@sha256:9f940409f96296ef56140bcc4665c204dd499af4c32c96cc00e792558097c3f1 +ARG AGENT_DIR +COPY ${AGENT_DIR} /opt/python \ No newline at end of file diff --git a/dev-utils/make-packages.sh b/dev-utils/make-packages.sh new file mode 100755 index 000000000..91b2a7bd1 --- /dev/null +++ b/dev-utils/make-packages.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# +# Make a Python APM agent distribution +# + +echo "::group::Install wheel" +pip install --user wheel +echo "::endgroup::" + +echo "::group::Building universal wheel" +python setup.py bdist_wheel +echo "::endgroup::" + +echo "::group::Building source distribution" +python setup.py sdist +echo "::endgroup::" diff --git a/dev-utils/requirements.txt b/dev-utils/requirements.txt index 21258b2ad..ccc3d9baf 100644 --- a/dev-utils/requirements.txt +++ b/dev-utils/requirements.txt @@ -1,4 +1,4 @@ # These are the pinned requirements for the lambda layer/docker image -certifi==2023.11.17 -urllib3==1.26.18 +certifi==2024.7.4 +urllib3==1.26.19 wrapt==1.14.1 diff --git a/docs/flask.asciidoc b/docs/flask.asciidoc index 9fb8f7e3e..b99ddd198 100644 --- a/docs/flask.asciidoc +++ b/docs/flask.asciidoc @@ -76,7 +76,7 @@ apm = ElasticAPM(app, service_name='', secret_token='') ===== Debug mode NOTE: Please note that errors and transactions will only be sent to the APM Server if your app is *not* in -http://flask.pocoo.org/docs/2.3.x/quickstart/#debug-mode[Flask debug mode]. +https://flask.palletsprojects.com/en/3.0.x/quickstart/#debug-mode[Flask debug mode]. To force the agent to send data while the app is in debug mode, set the value of `DEBUG` in the `ELASTIC_APM` dictionary to `True`: diff --git a/docs/logging.asciidoc b/docs/logging.asciidoc index 943de8f64..8f51edd50 100644 --- a/docs/logging.asciidoc +++ b/docs/logging.asciidoc @@ -36,7 +36,7 @@ as well as http://www.structlog.org/en/stable/[`structlog`]. [[logging]] ===== `logging` -For Python 3.2+, we use https://docs.python.org/3/library/logging.html#logging.setLogRecordFactory[`logging.setLogRecordFactory()`] +We use https://docs.python.org/3/library/logging.html#logging.setLogRecordFactory[`logging.setLogRecordFactory()`] to decorate the default LogRecordFactory to automatically add new attributes to each LogRecord object: @@ -51,25 +51,6 @@ You can disable this automatic behavior by using the <> setting in your configuration. -For Python versions <3.2, we also provide a -https://docs.python.org/3/library/logging.html#filter-objects[filter] which will -add the same new attributes to any filtered `LogRecord`: - -[source,python] ----- -import logging -from elasticapm.handlers.logging import LoggingFilter - -console = logging.StreamHandler() -console.addFilter(LoggingFilter()) -# add the handler to the root logger -logging.getLogger("").addHandler(console) ----- - -NOTE: Because https://docs.python.org/3/library/logging.html#filter-objects[filters -are not propagated to descendent loggers], you should add the filter to each of -your log handlers, as handlers are propagated, along with their attached filters. - [float] [[structlog]] ===== `structlog` diff --git a/docs/serverless-lambda.asciidoc b/docs/serverless-lambda.asciidoc index 48c091390..732abb2b4 100644 --- a/docs/serverless-lambda.asciidoc +++ b/docs/serverless-lambda.asciidoc @@ -5,6 +5,11 @@ The Python APM Agent can be used with AWS Lambda to monitor the execution of your AWS Lambda functions. +``` +Note: The Centralized Agent Configuration on the Elasticsearch APM currently does NOT support AWS Lambda. +``` + + [float] ==== Prerequisites diff --git a/docs/starlette.asciidoc b/docs/starlette.asciidoc index 77aaca0d4..941bf6d7a 100644 --- a/docs/starlette.asciidoc +++ b/docs/starlette.asciidoc @@ -42,10 +42,12 @@ app = Starlette() app.add_middleware(ElasticAPM) ---- -WARNING: If you are using any `BaseHTTPMiddleware` middleware, you must add them -*before* the ElasticAPM middleware. This is because `BaseHTTPMiddleware` breaks -`contextvar` propagation, as noted -https://www.starlette.io/middleware/#limitations[here]. +WARNING: `BaseHTTPMiddleware` breaks `contextvar` propagation, as noted +https://www.starlette.io/middleware/#limitations[here]. This means the +ElasticAPM middleware must be above any `BaseHTTPMiddleware` in the final +middleware list. If you're calling `add_middleware` repeatedly, add the +ElasticAPM middleware last. If you're passing in a list of middleware, +ElasticAPM should be first on that list. To configure the agent using initialization arguments: diff --git a/docs/supported-technologies.asciidoc b/docs/supported-technologies.asciidoc index 9a3b314d1..50198a102 100644 --- a/docs/supported-technologies.asciidoc +++ b/docs/supported-technologies.asciidoc @@ -42,6 +42,8 @@ We support these Django versions: * 3.1 * 3.2 * 4.0 + * 4.2 + * 5.0 For upcoming Django versions, we generally aim to ensure compatibility starting with the first Release Candidate. @@ -59,6 +61,10 @@ We support these Flask versions: * 1.0 * 1.1 * 2.0 + * 2.1 + * 2.2 + * 2.3 + * 3.0 [float] [[supported-aiohttp]] @@ -122,8 +128,8 @@ The Python APM agent comes with automatic instrumentation of various 3rd party m We support these Celery versions: -* 3.x -* 4.x +* 4.x (deprecated) +* 5.x Celery tasks will be recorded automatically with Django and Flask only. diff --git a/elasticapm/base.py b/elasticapm/base.py index 5f50dc79d..2c82f0d88 100644 --- a/elasticapm/base.py +++ b/elasticapm/base.py @@ -155,7 +155,8 @@ def __init__(self, config=None, **inline) -> None: "processors": self.load_processors(), } if config.transport_json_serializer: - transport_kwargs["json_serializer"] = config.transport_json_serializer + json_serializer_func = import_string(config.transport_json_serializer) + transport_kwargs["json_serializer"] = json_serializer_func self._api_endpoint_url = urllib.parse.urljoin( self.config.server_url if self.config.server_url.endswith("/") else self.config.server_url + "/", @@ -373,7 +374,7 @@ def get_service_info(self): def get_process_info(self): result = { "pid": os.getpid(), - "ppid": os.getppid() if hasattr(os, "getppid") else None, + "ppid": os.getppid(), "title": None, # Note: if we implement this, the value needs to be wrapped with keyword_field } if self.config.include_process_args: diff --git a/elasticapm/contrib/asgi.py b/elasticapm/contrib/asgi.py index 096fed36a..92ee6c193 100644 --- a/elasticapm/contrib/asgi.py +++ b/elasticapm/contrib/asgi.py @@ -50,6 +50,7 @@ async def wrapped_send(message) -> None: await set_context(lambda: middleware.get_data_from_response(message, constants.TRANSACTION), "response") result = "HTTP {}xx".format(message["status"] // 100) elasticapm.set_transaction_result(result, override=False) + elasticapm.set_transaction_outcome(http_status_code=message["status"], override=False) await send(message) return wrapped_send diff --git a/elasticapm/contrib/django/handlers.py b/elasticapm/contrib/django/handlers.py index 0c97e888d..c980acc4f 100644 --- a/elasticapm/contrib/django/handlers.py +++ b/elasticapm/contrib/django/handlers.py @@ -47,11 +47,11 @@ class LoggingHandler(BaseLoggingHandler): def __init__(self, level=logging.NOTSET) -> None: warnings.warn( - "The LoggingHandler will be deprecated in v7.0 of the agent. " + "The LoggingHandler is deprecated and will be removed in v7.0 of the agent. " "Please use `log_ecs_reformatting` and ship the logs with Elastic " "Agent or Filebeat instead. " "https://www.elastic.co/guide/en/apm/agent/python/current/logs.html", - PendingDeprecationWarning, + DeprecationWarning, ) # skip initialization of BaseLoggingHandler logging.Handler.__init__(self, level=level) diff --git a/elasticapm/contrib/flask/__init__.py b/elasticapm/contrib/flask/__init__.py index e9ec3323e..fdb6906dd 100644 --- a/elasticapm/contrib/flask/__init__.py +++ b/elasticapm/contrib/flask/__init__.py @@ -87,7 +87,7 @@ def __init__(self, app=None, client=None, client_cls=Client, logging=False, **de if self.logging: warnings.warn( "Flask log shipping is deprecated. See the Flask docs for more info and alternatives.", - PendingDeprecationWarning, + DeprecationWarning, ) self.client = client or get_client() self.client_cls = client_cls diff --git a/elasticapm/contrib/grpc/async_server_interceptor.py b/elasticapm/contrib/grpc/async_server_interceptor.py index 5af0c1372..e7c9b659f 100644 --- a/elasticapm/contrib/grpc/async_server_interceptor.py +++ b/elasticapm/contrib/grpc/async_server_interceptor.py @@ -33,20 +33,18 @@ import grpc import elasticapm -from elasticapm.contrib.grpc.server_interceptor import _ServicerContextWrapper, _wrap_rpc_behavior, get_trace_parent +from elasticapm.contrib.grpc.server_interceptor import _ServicerContextWrapper, get_trace_parent class _AsyncServerInterceptor(grpc.aio.ServerInterceptor): async def intercept_service(self, continuation, handler_call_details): - def transaction_wrapper(behavior, request_streaming, response_streaming): - async def _interceptor(request_or_iterator, context): - if request_streaming or response_streaming: # only unary-unary is supported - return behavior(request_or_iterator, context) + def wrap_unary_unary(behavior): + async def _interceptor(request, context): tp = get_trace_parent(handler_call_details) client = elasticapm.get_client() transaction = client.begin_transaction("request", trace_parent=tp) try: - result = behavior(request_or_iterator, _ServicerContextWrapper(context, transaction)) + result = behavior(request, _ServicerContextWrapper(context, transaction)) # This is so we can support both sync and async rpc functions if inspect.isawaitable(result): @@ -65,4 +63,12 @@ async def _interceptor(request_or_iterator, context): return _interceptor - return _wrap_rpc_behavior(await continuation(handler_call_details), transaction_wrapper) + handler = await continuation(handler_call_details) + if handler.request_streaming or handler.response_streaming: + return handler + + return grpc.unary_unary_rpc_method_handler( + wrap_unary_unary(handler.unary_unary), + request_deserializer=handler.request_deserializer, + response_serializer=handler.response_serializer, + ) diff --git a/elasticapm/contrib/opentracing/__init__.py b/elasticapm/contrib/opentracing/__init__.py index 8fbc99b19..71619ea20 100644 --- a/elasticapm/contrib/opentracing/__init__.py +++ b/elasticapm/contrib/opentracing/__init__.py @@ -36,8 +36,8 @@ warnings.warn( ( - "The OpenTracing bridge will be deprecated in the next major release. " + "The OpenTracing bridge is deprecated and will be removed in the next major release. " "Please migrate to the OpenTelemetry bridge." ), - PendingDeprecationWarning, + DeprecationWarning, ) diff --git a/elasticapm/contrib/serverless/aws.py b/elasticapm/contrib/serverless/aws.py index 26f37bdfb..1717d57cc 100644 --- a/elasticapm/contrib/serverless/aws.py +++ b/elasticapm/contrib/serverless/aws.py @@ -71,6 +71,9 @@ def capture_serverless(func: Optional[callable] = None, **kwargs) -> callable: @capture_serverless def handler(event, context): return {"statusCode": r.status_code, "body": "Success!"} + + Please note that when using the APM Layer and setting AWS_LAMBDA_EXEC_WRAPPER this is not required and + the handler would be instrumented automatically. """ if not func: # This allows for `@capture_serverless()` in addition to @@ -135,6 +138,18 @@ def prep_kwargs(kwargs=None): return kwargs +def should_normalize_headers(event: dict) -> bool: + """ + Helper to decide if we should normalize headers or not depending on the event + + Even if the documentation says that headers are lowercased it's not always the case for format version 1.0 + https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + """ + + request_context = event.get("requestContext", {}) + return ("elb" in request_context or "requestId" in request_context) and "http" not in request_context + + class _lambda_transaction(object): """ Context manager for creating transactions around AWS Lambda functions. @@ -162,7 +177,13 @@ def __enter__(self): # service like Step Functions, and is unlikely to be standardized # in any way. We just have to rely on our defaults in this case. self.event = {} - trace_parent = TraceParent.from_headers(self.event.get("headers") or {}) + + headers = self.event.get("headers") or {} + if headers and should_normalize_headers(self.event): + normalized_headers = {k.lower(): v for k, v in headers.items()} + else: + normalized_headers = headers + trace_parent = TraceParent.from_headers(normalized_headers) global COLD_START cold_start = COLD_START diff --git a/elasticapm/contrib/serverless/azure.py b/elasticapm/contrib/serverless/azure.py index ed2444d60..c5df4882a 100644 --- a/elasticapm/contrib/serverless/azure.py +++ b/elasticapm/contrib/serverless/azure.py @@ -43,8 +43,6 @@ from elasticapm.utils.disttracing import TraceParent from elasticapm.utils.logging import get_logger -SERVERLESS_HTTP_REQUEST = ("api", "elb") - logger = get_logger("elasticapm.serverless") _AnnotatedFunctionT = TypeVar("_AnnotatedFunctionT") diff --git a/elasticapm/contrib/starlette/__init__.py b/elasticapm/contrib/starlette/__init__.py index a6262ba86..3dfb225c9 100644 --- a/elasticapm/contrib/starlette/__init__.py +++ b/elasticapm/contrib/starlette/__init__.py @@ -36,6 +36,7 @@ from typing import Dict, Optional import starlette +from starlette.datastructures import Headers from starlette.requests import Request from starlette.routing import Match, Mount from starlette.types import ASGIApp, Message @@ -105,7 +106,7 @@ class ElasticAPM: >>> elasticapm.capture_message('hello, world!') """ - def __init__(self, app: ASGIApp, client: Optional[Client], **kwargs) -> None: + def __init__(self, app: ASGIApp, client: Optional[Client] = None, **kwargs) -> None: """ Args: @@ -146,11 +147,16 @@ async def wrapped_send(message) -> None: ) result = "HTTP {}xx".format(message["status"] // 100) elasticapm.set_transaction_result(result, override=False) + elasticapm.set_transaction_outcome(http_status_code=message["status"], override=False) await send(message) _mocked_receive = None _request_receive = None + # begin the transaction before capturing the body to get that time accounted + trace_parent = TraceParent.from_headers(dict(Headers(scope=scope))) + self.client.begin_transaction("request", trace_parent=trace_parent) + if self.client.config.capture_body != "off": # When we consume the body from receive, we replace the streaming @@ -234,9 +240,6 @@ async def _request_started(self, request: Request) -> None: if self.client.config.capture_body != "off": await get_body(request) - trace_parent = TraceParent.from_headers(dict(request.headers)) - self.client.begin_transaction("request", trace_parent=trace_parent) - await set_context(lambda: get_data_from_request(request, self.client.config, constants.TRANSACTION), "request") transaction_name = self.get_route_name(request) or request.url.path elasticapm.set_transaction_name("{} {}".format(request.method, transaction_name), override=False) diff --git a/elasticapm/handlers/logging.py b/elasticapm/handlers/logging.py index 4407f0f87..96718d2db 100644 --- a/elasticapm/handlers/logging.py +++ b/elasticapm/handlers/logging.py @@ -51,7 +51,7 @@ def __init__(self, *args, **kwargs) -> None: "the agent. Please use `log_ecs_reformatting` and ship the logs " "with Elastic Agent or Filebeat instead. " "https://www.elastic.co/guide/en/apm/agent/python/current/logs.html", - PendingDeprecationWarning, + DeprecationWarning, ) self.client = None if "client" in kwargs: @@ -66,12 +66,9 @@ def __init__(self, *args, **kwargs) -> None: if client_cls: self.client = client_cls(*args, **kwargs) else: - # In 6.0, this should raise a ValueError warnings.warn( - "LoggingHandler requires a Client instance. No Client was " - "received. This will result in an error starting in v6.0 " - "of the agent", - PendingDeprecationWarning, + "LoggingHandler requires a Client instance. No Client was received.", + DeprecationWarning, ) self.client = Client(*args, **kwargs) logging.Handler.__init__(self, level=kwargs.get("level", logging.NOTSET)) @@ -194,6 +191,16 @@ class LoggingFilter(logging.Filter): automatically. """ + def __init__(self, name=""): + super().__init__(name=name) + warnings.warn( + "The LoggingFilter is deprecated and will be removed in v7.0 of " + "the agent. On Python 3.2+, by default we add a LogRecordFactory to " + "your root logger automatically" + "https://www.elastic.co/guide/en/apm/agent/python/current/logs.html", + DeprecationWarning, + ) + def filter(self, record): """ Add elasticapm attributes to `record`. diff --git a/elasticapm/instrumentation/packages/dbapi2.py b/elasticapm/instrumentation/packages/dbapi2.py index fb49723c2..fa1d0f31e 100644 --- a/elasticapm/instrumentation/packages/dbapi2.py +++ b/elasticapm/instrumentation/packages/dbapi2.py @@ -34,6 +34,7 @@ """ import re +import string import wrapt @@ -85,6 +86,7 @@ def scan(tokens): literal_started = None prev_was_escape = False lexeme = [] + digits = set(string.digits) i = 0 while i < len(tokens): @@ -114,6 +116,11 @@ def scan(tokens): literal_start_idx = i literal_started = token elif token == "$": + # exclude query parameters that have a digit following the dollar + if True and len(tokens) > i + 1 and tokens[i + 1] in digits: + yield i, token + i += 1 + continue # Postgres can use arbitrary characters between two $'s as a # literal separation token, e.g.: $fish$ literal $fish$ # This part will detect that and skip over the literal. diff --git a/elasticapm/instrumentation/packages/kafka.py b/elasticapm/instrumentation/packages/kafka.py index c3bc2d64d..ab9ebd1a4 100644 --- a/elasticapm/instrumentation/packages/kafka.py +++ b/elasticapm/instrumentation/packages/kafka.py @@ -143,7 +143,8 @@ def call(self, module, method, wrapped, instance, args, kwargs): try: result = wrapped(*args, **kwargs) except StopIteration: - span.cancel() + if span: + span.cancel() raise if span and not isinstance(span, DroppedSpan): topic = result[0] diff --git a/elasticapm/instrumentation/packages/urllib.py b/elasticapm/instrumentation/packages/urllib.py index b40932a55..2b0dae16e 100644 --- a/elasticapm/instrumentation/packages/urllib.py +++ b/elasticapm/instrumentation/packages/urllib.py @@ -97,10 +97,9 @@ def call(self, module, method, wrapped, instance, args, kwargs): leaf_span.dist_tracing_propagated = True response = wrapped(*args, **kwargs) if response: - status = getattr(response, "status", None) or response.getcode() # Python 2 compat if span.context: - span.context["http"]["status_code"] = status - span.set_success() if status < 400 else span.set_failure() + span.context["http"]["status_code"] = response.status + span.set_success() if response.status < 400 else span.set_failure() return response def mutate_unsampled_call_args(self, module, method, wrapped, instance, args, kwargs, transaction): diff --git a/elasticapm/instrumentation/packages/urllib3.py b/elasticapm/instrumentation/packages/urllib3.py index cc7206e83..93d9c3392 100644 --- a/elasticapm/instrumentation/packages/urllib3.py +++ b/elasticapm/instrumentation/packages/urllib3.py @@ -61,12 +61,7 @@ def update_headers(args, kwargs, instance, transaction, trace_parent): :param trace_parent: the TraceParent object :return: an (args, kwargs) tuple """ - from urllib3._version import __version__ as urllib3_version - - if urllib3_version.startswith("2") and len(args) >= 5 and args[4]: - headers = args[4].copy() - args = tuple(itertools.chain((args[:4]), (headers,), args[5:])) - elif len(args) >= 4 and args[3]: + if len(args) >= 4 and args[3]: headers = args[3].copy() args = tuple(itertools.chain((args[:3]), (headers,), args[4:])) elif "headers" in kwargs and kwargs["headers"]: diff --git a/elasticapm/transport/base.py b/elasticapm/transport/base.py index 487e13a93..24911c395 100644 --- a/elasticapm/transport/base.py +++ b/elasticapm/transport/base.py @@ -292,7 +292,8 @@ def close(self) -> None: if not self._flushed.wait(timeout=self._max_flush_time_seconds): logger.error("Closing the transport connection timed out.") - stop_thread = close + def stop_thread(self) -> None: + self.close() def flush(self): """ diff --git a/elasticapm/transport/http.py b/elasticapm/transport/http.py index 17ae50ba3..ed132068c 100644 --- a/elasticapm/transport/http.py +++ b/elasticapm/transport/http.py @@ -32,6 +32,7 @@ import hashlib import json +import os import re import ssl import urllib.parse @@ -250,6 +251,23 @@ def ca_certs(self): return self._server_ca_cert_file return certifi.where() if (certifi and self.client.config.use_certifi) else None + def close(self): + """ + Take care of being able to shutdown cleanly + :return: + """ + if self._closed or (not self._thread or self._thread.pid != os.getpid()): + return + + self._closed = True + # we are racing against urllib3 ConnectionPool weakref finalizer that would lead to having them closed + # and we hanging waiting for send any eventual queued data + # Force the creation of a new PoolManager so that we are always able to flush + self._http = None + self.queue("close", None) + if not self._flushed.wait(timeout=self._max_flush_time_seconds): + logger.error("Closing the transport connection timed out.") + def version_string_to_tuple(version): if version: diff --git a/elasticapm/utils/__init__.py b/elasticapm/utils/__init__.py index 58a302960..0f7b52c0d 100644 --- a/elasticapm/utils/__init__.py +++ b/elasticapm/utils/__init__.py @@ -33,20 +33,14 @@ import re import socket import urllib.parse -from functools import partial +from functools import partial, partialmethod from types import FunctionType from typing import Pattern from elasticapm.conf import constants from elasticapm.utils import encoding -try: - from functools import partialmethod - - partial_types = (partial, partialmethod) -except ImportError: - # Python 2 - partial_types = (partial,) +partial_types = (partial, partialmethod) default_ports = {"https": 443, "http": 80, "postgresql": 5432, "mysql": 3306, "mssql": 1433} diff --git a/elasticapm/utils/json_encoder.py b/elasticapm/utils/json_encoder.py index c40e0accd..3918bb233 100644 --- a/elasticapm/utils/json_encoder.py +++ b/elasticapm/utils/json_encoder.py @@ -31,13 +31,9 @@ import datetime import decimal +import json import uuid -try: - import json -except ImportError: - import simplejson as json - class BetterJSONEncoder(json.JSONEncoder): ENCODERS = { diff --git a/elasticapm/utils/simplejson_encoder.py b/elasticapm/utils/simplejson_encoder.py new file mode 100644 index 000000000..f538ffdac --- /dev/null +++ b/elasticapm/utils/simplejson_encoder.py @@ -0,0 +1,58 @@ +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + + +import simplejson as json + +from elasticapm.utils.json_encoder import BetterJSONEncoder + + +class BetterSimpleJSONEncoder(json.JSONEncoder): + ENCODERS = BetterJSONEncoder.ENCODERS + + def default(self, obj): + if type(obj) in self.ENCODERS: + return self.ENCODERS[type(obj)](obj) + try: + return super(BetterSimpleJSONEncoder, self).default(obj) + except TypeError: + return str(obj) + + +def better_decoder(data): + return data + + +def dumps(value, **kwargs): + return json.dumps(value, cls=BetterSimpleJSONEncoder, ignore_nan=True, **kwargs) + + +def loads(value, **kwargs): + return json.loads(value, object_hook=better_decoder) diff --git a/elasticapm/version.py b/elasticapm/version.py index ea64e853b..e9eff8543 100644 --- a/elasticapm/version.py +++ b/elasticapm/version.py @@ -28,5 +28,5 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = (6, 20, 0) +__version__ = (6, 23, 0) VERSION = ".".join(map(str, __version__)) diff --git a/pyproject.toml b/pyproject.toml index 167532517..019a7b666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,6 @@ exclude = ''' | _build | build | dist - | elasticapm/utils/wrapt # The following are specific to Black, you probably don't want those. | blib2to3 diff --git a/scripts/run-tests.bat b/scripts/run-tests.bat index 0fce63000..f17e57d9c 100644 --- a/scripts/run-tests.bat +++ b/scripts/run-tests.bat @@ -9,14 +9,12 @@ set VENV_PYTHON=%cd%\venv\Scripts\ set COVERAGE_FILE=.coverage.windows.%VERSION%.%FRAMEWORK%.%ASYNCIO% -set IGNORE_PYTHON3_WITH_PYTHON2= -if "%VERSION%" == "2.7" set IGNORE_PYTHON3_WITH_PYTHON2=--ignore-glob="*\py3_*.py" set PYTEST_JUNIT="--junitxml=.\tests\windows-%VERSION%-%FRAMEWORK%-%ASYNCIO%-python-agent-junit.xml" if "%ASYNCIO%" == "true" ( - %VENV_PYTHON%\python.exe -m pytest %PYTEST_JUNIT% %IGNORE_PYTHON3_WITH_PYTHON2% --cov --cov-context=test --cov-branch --cov-config=setup.cfg -m "not integrationtest" || exit /b 1 + %VENV_PYTHON%\python.exe -m pytest %PYTEST_JUNIT% --cov --cov-context=test --cov-branch --cov-config=setup.cfg -m "not integrationtest" || exit /b 1 ) if "%ASYNCIO%" == "false" ( - %VENV_PYTHON%\python.exe -m pytest %PYTEST_JUNIT% --ignore-glob="*\asyncio*\*" %IGNORE_PYTHON3_WITH_PYTHON2% --cov --cov-context=test --cov-branch --cov-config=setup.cfg -m "not integrationtest" || exit /b 1 + %VENV_PYTHON%\python.exe -m pytest %PYTEST_JUNIT% --ignore-glob="*\asyncio*\*" --cov --cov-context=test --cov-branch --cov-config=setup.cfg -m "not integrationtest" || exit /b 1 ) call %VENV_PYTHON%\python.exe setup.py bdist_wheel diff --git a/setup.cfg b/setup.cfg index ce33450a6..2dca4283e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -39,7 +39,7 @@ zip_safe = false install_requires = urllib3!=2.0.0,<3.0.0 certifi - wrapt>=1.14.1,<1.15.0 # https://github.com/elastic/apm-agent-python/issues/1894 + wrapt>=1.14.1,!=1.15.0 # https://github.com/elastic/apm-agent-python/issues/1894 ecs_logging test_suite=tests diff --git a/setup.py b/setup.py index 23ebec33e..5dbb8f643 100644 --- a/setup.py +++ b/setup.py @@ -40,50 +40,16 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -# Hack to prevent stupid "TypeError: 'NoneType' object is not callable" error -# in multiprocessing/util.py _exit_function when running `python -# setup.py test` (see -# http://www.eby-sarna.com/pipermail/peak/2010-May/003357.html) -for m in ("multiprocessing", "billiard"): - try: - __import__(m) - except ImportError: - pass - import ast import codecs import os -import sys -from distutils.command.build_ext import build_ext -from distutils.errors import CCompilerError, DistutilsExecError, DistutilsPlatformError import pkg_resources -from setuptools import Extension, setup -from setuptools.command.test import test as TestCommand +from setuptools import setup pkg_resources.require("setuptools>=39.2") -class PyTest(TestCommand): - user_options = [("pytest-args=", "a", "Arguments to pass to py.test")] - - def initialize_options(self) -> None: - TestCommand.initialize_options(self) - self.pytest_args = [] - - def finalize_options(self) -> None: - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True - - def run_tests(self) -> None: - # import here, cause outside the eggs aren't loaded - import pytest - - errno = pytest.main(self.pytest_args) - sys.exit(errno) - - def get_version(): """ Get version without importing from elasticapm. This avoids any side effects @@ -100,8 +66,12 @@ def get_version(): for line in version_file: if line.startswith("__version__"): version_tuple = ast.literal_eval(line.split(" = ")[1]) - return ".".join(map(str, version_tuple)) + version_str = ".".join(map(str, version_tuple)) + post_version = os.getenv("ELASTIC_CI_POST_VERSION") + if post_version: + return f"{version_str}.post{post_version}" + return version_str return "unknown" -setup(cmdclass={"test": PyTest}, version=get_version()) +setup(version=get_version()) diff --git a/tests/client/client_tests.py b/tests/client/client_tests.py index 6cec88205..a61248c85 100644 --- a/tests/client/client_tests.py +++ b/tests/client/client_tests.py @@ -48,6 +48,11 @@ import elasticapm from elasticapm.base import Client from elasticapm.conf.constants import ERROR + +try: + from elasticapm.utils.simplejson_encoder import dumps as simplejson_dumps +except ImportError: + simplejson_dumps = None from tests.fixtures import DummyTransport, TempStoreClient from tests.utils import assert_any_record_contains @@ -77,11 +82,7 @@ def test_service_info_node_name(elasticapm_client): def test_process_info(elasticapm_client): process_info = elasticapm_client.get_process_info() assert process_info["pid"] == os.getpid() - if hasattr(os, "getppid"): - assert process_info["ppid"] == os.getppid() - else: - # Windows + Python 2.7 - assert process_info["ppid"] is None + assert process_info["ppid"] == os.getppid() assert "argv" not in process_info elasticapm_client.config.update("1", include_process_args=True) with mock.patch.object(sys, "argv", ["a", "b", "c"]): @@ -232,6 +233,14 @@ def test_custom_transport(elasticapm_client): assert isinstance(elasticapm_client._transport, DummyTransport) +@pytest.mark.skipif(simplejson_dumps is None, reason="no test without simplejson") +@pytest.mark.parametrize( + "elasticapm_client", [{"transport_json_serializer": "elasticapm.utils.simplejson_encoder.dumps"}], indirect=True +) +def test_custom_transport_json_serializer(elasticapm_client): + assert elasticapm_client._transport._json_serializer == simplejson_dumps + + @pytest.mark.parametrize("elasticapm_client", [{"processors": []}], indirect=True) def test_empty_processor_list(elasticapm_client): assert elasticapm_client.processors == [] diff --git a/tests/contrib/asgi/app.py b/tests/contrib/asgi/app.py index a919b2cef..352720135 100644 --- a/tests/contrib/asgi/app.py +++ b/tests/contrib/asgi/app.py @@ -59,3 +59,8 @@ async def boom() -> None: @app.route("/body") async def json(): return jsonify({"hello": "world"}) + + +@app.route("/500", methods=["GET"]) +async def error(): + return "KO", 500 diff --git a/tests/contrib/asgi/asgi_tests.py b/tests/contrib/asgi/asgi_tests.py index 824a23b68..f2a096dcc 100644 --- a/tests/contrib/asgi/asgi_tests.py +++ b/tests/contrib/asgi/asgi_tests.py @@ -66,6 +66,23 @@ async def test_transaction_span(instrumented_app, elasticapm_client): assert span["sync"] == False +@pytest.mark.asyncio +async def test_transaction_span(instrumented_app, elasticapm_client): + async with async_asgi_testclient.TestClient(instrumented_app) as client: + resp = await client.get("/500") + assert resp.status_code == 500 + assert resp.text == "KO" + + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + assert len(elasticapm_client.events[constants.SPAN]) == 0 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + assert transaction["name"] == "GET unknown route" + assert transaction["result"] == "HTTP 5xx" + assert transaction["outcome"] == "failure" + assert transaction["context"]["request"]["url"]["full"] == "/500" + assert transaction["context"]["response"]["status_code"] == 500 + + @pytest.mark.asyncio async def test_transaction_ignore_url(instrumented_app, elasticapm_client): elasticapm_client.config.update("1", transaction_ignore_urls="/foo*") diff --git a/tests/contrib/asyncio/starlette_tests.py b/tests/contrib/asyncio/starlette_tests.py index 5f4c070bd..e3c4f4a16 100644 --- a/tests/contrib/asyncio/starlette_tests.py +++ b/tests/contrib/asyncio/starlette_tests.py @@ -110,6 +110,10 @@ async def with_slash(request): async def without_slash(request): return PlainTextResponse("Hi {}".format(request.path_params["name"])) + @app.route("/500/", methods=["GET"]) + async def with_500_status_code(request): + return PlainTextResponse("Oops", status_code=500) + @sub.route("/hi") async def hi_from_sub(request): return PlainTextResponse("sub") @@ -236,6 +240,27 @@ def test_exception(app, elasticapm_client): assert error["context"]["request"] == transaction["context"]["request"] +def test_failure_outcome_with_500_status_code(app, elasticapm_client): + client = TestClient(app) + + client.get("/500/") + + assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + transaction = elasticapm_client.events[constants.TRANSACTION][0] + spans = elasticapm_client.spans_for_transaction(transaction) + assert len(spans) == 0 + + assert transaction["name"] == "GET /500/" + assert transaction["result"] == "HTTP 5xx" + assert transaction["outcome"] == "failure" + assert transaction["type"] == "request" + request = transaction["context"]["request"] + assert request["method"] == "GET" + assert transaction["context"]["response"]["status_code"] == 500 + + assert len(elasticapm_client.events[constants.ERROR]) == 0 + + @pytest.mark.parametrize("header_name", [constants.TRACEPARENT_HEADER_NAME, constants.TRACEPARENT_LEGACY_HEADER_NAME]) def test_traceparent_handling(app, elasticapm_client, header_name): client = TestClient(app) @@ -534,3 +559,11 @@ def test_transaction_active_in_base_exception_handler(app, elasticapm_client): assert exc.transaction_id assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + + +def test_middleware_without_client_arg(): + with mock.patch.dict("os.environ", {"ELASTIC_APM_SERVICE_NAME": "foo"}): + app = Starlette() + elasticapm = ElasticAPM(app) + + assert elasticapm.client.config.service_name == "foo" diff --git a/tests/contrib/django/django_tests.py b/tests/contrib/django/django_tests.py index 547d46b51..535729bcf 100644 --- a/tests/contrib/django/django_tests.py +++ b/tests/contrib/django/django_tests.py @@ -270,25 +270,7 @@ def test_user_info_with_custom_user_non_string_username(django_elasticapm_client assert user_info["username"] == "1" -@pytest.mark.skipif(django.VERSION > (1, 9), reason="MIDDLEWARE_CLASSES removed in Django 2.0") def test_user_info_with_non_django_auth(django_elasticapm_client, client): - with override_settings( - INSTALLED_APPS=[app for app in settings.INSTALLED_APPS if app != "django.contrib.auth"] - ) and override_settings( - MIDDLEWARE_CLASSES=[ - m for m in settings.MIDDLEWARE_CLASSES if m != "django.contrib.auth.middleware.AuthenticationMiddleware" - ] - ): - with pytest.raises(Exception): - resp = client.get(reverse("elasticapm-raise-exc")) - - assert len(django_elasticapm_client.events[ERROR]) == 1 - event = django_elasticapm_client.events[ERROR][0] - assert event["context"]["user"] == {} - - -@pytest.mark.skipif(django.VERSION < (1, 10), reason="MIDDLEWARE new in Django 1.10") -def test_user_info_with_non_django_auth_django_2(django_elasticapm_client, client): with override_settings( INSTALLED_APPS=[app for app in settings.INSTALLED_APPS if app != "django.contrib.auth"] ) and override_settings( @@ -303,22 +285,7 @@ def test_user_info_with_non_django_auth_django_2(django_elasticapm_client, clien assert event["context"]["user"] == {} -@pytest.mark.skipif(django.VERSION > (1, 9), reason="MIDDLEWARE_CLASSES removed in Django 2.0") def test_user_info_without_auth_middleware(django_elasticapm_client, client): - with override_settings( - MIDDLEWARE_CLASSES=[ - m for m in settings.MIDDLEWARE_CLASSES if m != "django.contrib.auth.middleware.AuthenticationMiddleware" - ] - ): - with pytest.raises(Exception): - client.get(reverse("elasticapm-raise-exc")) - assert len(django_elasticapm_client.events[ERROR]) == 1 - event = django_elasticapm_client.events[ERROR][0] - assert event["context"]["user"] == {} - - -@pytest.mark.skipif(django.VERSION < (1, 10), reason="MIDDLEWARE new in Django 1.10") -def test_user_info_without_auth_middleware_django_2(django_elasticapm_client, client): with override_settings( MIDDLEWARE_CLASSES=None, MIDDLEWARE=[m for m in settings.MIDDLEWARE if m != "django.contrib.auth.middleware.AuthenticationMiddleware"], @@ -614,8 +581,7 @@ def read(): assert_any_record_contains(caplog.records, "Can't capture request body: foobar") -@pytest.mark.skipif(django.VERSION < (1, 9), reason="get-raw-uri-not-available") -def test_disallowed_hosts_error_django_19(django_elasticapm_client): +def test_disallowed_hosts_error(django_elasticapm_client): request = WSGIRequest( environ={ "wsgi.input": io.BytesIO(), @@ -634,26 +600,6 @@ def test_disallowed_hosts_error_django_19(django_elasticapm_client): assert event["context"]["request"]["url"]["full"] == "http://testserver/" -@pytest.mark.skipif(django.VERSION >= (1, 9), reason="get-raw-uri-available") -def test_disallowed_hosts_error_django_18(django_elasticapm_client): - request = WSGIRequest( - environ={ - "wsgi.input": io.BytesIO(), - "wsgi.url_scheme": "http", - "REQUEST_METHOD": "POST", - "SERVER_NAME": "testserver", - "SERVER_PORT": "80", - "CONTENT_TYPE": "application/json", - "ACCEPT": "application/json", - } - ) - with override_settings(ALLOWED_HOSTS=["example.com"]): - # this should not raise a DisallowedHost exception - django_elasticapm_client.capture("Message", message="foo", request=request) - event = django_elasticapm_client.events[ERROR][0] - assert event["context"]["request"]["url"] == {"full": "DisallowedHost"} - - @pytest.mark.parametrize( "django_elasticapm_client", [{"capture_body": "errors"}, {"capture_body": "all"}, {"capture_body": "off"}], @@ -1196,16 +1142,6 @@ def test_stacktrace_filtered_for_elasticapm(client, django_elasticapm_client): assert spans[1]["stacktrace"][0]["module"].startswith("django.template"), spans[1]["stacktrace"][0]["function"] -@pytest.mark.skipif(django.VERSION > (1, 7), reason="argparse raises CommandError in this case") -@mock.patch("elasticapm.contrib.django.management.commands.elasticapm.Command._get_argv") -def test_subcommand_not_set(argv_mock): - stdout = io.StringIO() - argv_mock.return_value = ["manage.py", "elasticapm"] - call_command("elasticapm", stdout=stdout) - output = stdout.getvalue() - assert "No command specified" in output - - @mock.patch("elasticapm.contrib.django.management.commands.elasticapm.Command._get_argv") def test_subcommand_not_known(argv_mock): stdout = io.StringIO() @@ -1317,8 +1253,8 @@ def test_settings_server_url_with_credentials(): @pytest.mark.skipif( - not ((1, 10) <= django.VERSION < (2, 0)), - reason="only needed in 1.10 and 1.11 when both middleware settings are valid", + django.VERSION >= (2, 0), + reason="only needed in 1.11 when both middleware settings are valid", ) def test_django_1_10_uses_deprecated_MIDDLEWARE_CLASSES(): stdout = io.StringIO() diff --git a/tests/contrib/serverless/aws_elb_test_data.json b/tests/contrib/serverless/aws_elb_test_data.json index 87e05ac85..79b4dc6dd 100644 --- a/tests/contrib/serverless/aws_elb_test_data.json +++ b/tests/contrib/serverless/aws_elb_test_data.json @@ -15,6 +15,7 @@ "connection": "Keep-Alive", "host": "blabla.com", "user-agent": "Apache-HttpClient/4.5.13 (Java/11.0.15)", + "TraceParent": "00-12345678901234567890123456789012-1234567890123456-01", "x-amzn-trace-id": "Root=1-xxxxxxxxxxxxxx", "x-forwarded-for": "199.99.99.999", "x-forwarded-port": "443", diff --git a/tests/contrib/serverless/aws_tests.py b/tests/contrib/serverless/aws_tests.py index 9f4a7253f..df062a378 100644 --- a/tests/contrib/serverless/aws_tests.py +++ b/tests/contrib/serverless/aws_tests.py @@ -36,7 +36,12 @@ from elasticapm import capture_span from elasticapm.conf import constants -from elasticapm.contrib.serverless.aws import capture_serverless, get_data_from_request, get_data_from_response +from elasticapm.contrib.serverless.aws import ( + capture_serverless, + get_data_from_request, + get_data_from_response, + should_normalize_headers, +) @pytest.fixture @@ -300,6 +305,7 @@ def test_func(event, context): assert transaction["context"]["request"]["headers"] assert transaction["context"]["response"]["status_code"] == 200 assert transaction["context"]["service"]["origin"]["name"] == "lambda-279XGJDqGZ5rsrHC2Fjr" + assert transaction["trace_id"] == "12345678901234567890123456789012" def test_capture_serverless_s3(event_s3, context, elasticapm_client): @@ -477,3 +483,17 @@ def test_func(event, context): test_func(event_api2, context) assert len(elasticapm_client.events[constants.TRANSACTION]) == 1 + + +def test_should_normalize_headers_true(event_api, event_elb): + assert should_normalize_headers(event_api) is True + assert should_normalize_headers(event_elb) is True + + +def test_should_normalize_headers_false(event_api2, event_lurl, event_s3, event_s3_batch, event_sqs, event_sns): + assert should_normalize_headers(event_api2) is False + assert should_normalize_headers(event_lurl) is False + assert should_normalize_headers(event_s3) is False + assert should_normalize_headers(event_s3_batch) is False + assert should_normalize_headers(event_sqs) is False + assert should_normalize_headers(event_sns) is False diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml index 48b43cda2..62a05c83f 100644 --- a/tests/docker-compose.yml +++ b/tests/docker-compose.yml @@ -130,7 +130,7 @@ services: - pymssqldata:/var/opt/mssql mysql: - image: mysql + image: mysql:8.0 command: --default-authentication-plugin=mysql_native_password --log_error_verbosity=3 environment: - MYSQL_DATABASE=eapm_tests diff --git a/tests/fixtures.py b/tests/fixtures.py index 94e89f961..ddeaa1f5b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -46,6 +46,7 @@ import zlib from collections import defaultdict from typing import Optional +from urllib.request import pathname2url import jsonschema import mock @@ -61,12 +62,6 @@ from elasticapm.transport.http_base import HTTPTransportBase from elasticapm.utils.threading import ThreadManager -try: - from urllib.request import pathname2url -except ImportError: - # Python 2 - from urllib import pathname2url - cur_dir = os.path.dirname(os.path.realpath(__file__)) ERRORS_SCHEMA = os.path.join(cur_dir, "upstream", "json-specs", "error.json") diff --git a/tests/handlers/logging/logging_tests.py b/tests/handlers/logging/logging_tests.py index 8e23a0b69..8cc8fc4f1 100644 --- a/tests/handlers/logging/logging_tests.py +++ b/tests/handlers/logging/logging_tests.py @@ -380,7 +380,7 @@ def test_logging_handler_no_client(recwarn): while True: # If we never find our desired warning this will eventually throw an # AssertionError - w = recwarn.pop(PendingDeprecationWarning) + w = recwarn.pop(DeprecationWarning) if "LoggingHandler requires a Client instance" in w.message.args[0]: return True diff --git a/tests/instrumentation/dbapi2_tests.py b/tests/instrumentation/dbapi2_tests.py index 3d72b6632..089571715 100644 --- a/tests/instrumentation/dbapi2_tests.py +++ b/tests/instrumentation/dbapi2_tests.py @@ -122,6 +122,20 @@ def test_extract_signature_bytes(): assert actual == expected +def test_extract_signature_pathological(): + # tune for performance testing + multiplier = 10 + values = [] + for chunk in range(multiplier): + i = chunk * 3 + values.append(f" (${1+i}::varchar, ${2+i}::varchar, ${3+i}::varchar), ") + + sql = f"SELECT * FROM (VALUES {''.join(values)})\n" + actual = extract_signature(sql) + expected = "SELECT FROM" + assert actual == expected + + @pytest.mark.parametrize( ["sql", "expected"], [ diff --git a/tests/instrumentation/kafka_tests.py b/tests/instrumentation/kafka_tests.py index 71416c130..0bfc5c496 100644 --- a/tests/instrumentation/kafka_tests.py +++ b/tests/instrumentation/kafka_tests.py @@ -45,11 +45,10 @@ pytestmark = [pytest.mark.kafka] -if "KAFKA_HOST" not in os.environ: +KAFKA_HOST = os.environ.get("KAFKA_HOST") +if not KAFKA_HOST: pytestmark.append(pytest.mark.skip("Skipping kafka tests, no KAFKA_HOST environment variable set")) -KAFKA_HOST = os.environ["KAFKA_HOST"] - @pytest.fixture(scope="function") def topics(): @@ -233,3 +232,22 @@ def test_kafka_poll_unsampled_transaction(instrument, elasticapm_client, consume elasticapm_client.end_transaction("foo") spans = elasticapm_client.events[SPAN] assert len(spans) == 0 + + +def test_kafka_consumer_unsampled_transaction_handles_stop_iteration( + instrument, elasticapm_client, producer, consumer, topics +): + def delayed_send(): + time.sleep(0.2) + producer.send("test", key=b"foo", value=b"bar") + + thread = threading.Thread(target=delayed_send) + thread.start() + transaction = elasticapm_client.begin_transaction("foo") + transaction.is_sampled = False + for item in consumer: + pass + thread.join() + elasticapm_client.end_transaction("foo") + spans = elasticapm_client.events[SPAN] + assert len(spans) == 0 diff --git a/tests/instrumentation/urllib3_tests.py b/tests/instrumentation/urllib3_tests.py index 8cc21ceb0..1fa03fa43 100644 --- a/tests/instrumentation/urllib3_tests.py +++ b/tests/instrumentation/urllib3_tests.py @@ -294,3 +294,20 @@ def test_instance_headers_are_respected( assert "kwargs" in request_headers if instance_headers and not (header_arg or header_kwarg): assert "instance" in request_headers + + +def test_connection_pool_urlopen_does_not_crash_with_many_args(instrument, elasticapm_client, waiting_httpserver): + """Mimics ConnectionPool.urlopen error path with broken connection, see #1928""" + waiting_httpserver.serve_content("") + url = waiting_httpserver.url + "/hello_world" + parsed_url = urllib.parse.urlparse(url) + pool = urllib3.HTTPConnectionPool( + parsed_url.hostname, + parsed_url.port, + maxsize=1, + block=True, + ) + retry = urllib3.util.Retry(10) + elasticapm_client.begin_transaction("transaction") + r = pool.urlopen("GET", url, None, {"args": "true"}, retry, False, False) + assert r.status == 200 diff --git a/tests/instrumentation/urllib_tests.py b/tests/instrumentation/urllib_tests.py index 3f2796483..fbf5fa44f 100644 --- a/tests/instrumentation/urllib_tests.py +++ b/tests/instrumentation/urllib_tests.py @@ -114,7 +114,7 @@ def test_urllib_error(instrument, elasticapm_client, waiting_httpserver, status_ @mock.patch(request_method) @mock.patch(getresponse_method) def test_urllib_standard_port(mock_getresponse, mock_request, instrument, elasticapm_client): - # "code" is needed for Python 3, "status" for Python 2 + # Python internally used both "code" and "status" mock_getresponse.return_value = mock.Mock(code=200, status=200) url = "http://example.com/" diff --git a/tests/requirements/reqs-asgi-2.txt b/tests/requirements/reqs-asgi-2.txt index eecc89d9a..97ff3022d 100644 --- a/tests/requirements/reqs-asgi-2.txt +++ b/tests/requirements/reqs-asgi-2.txt @@ -1,6 +1,6 @@ quart==0.6.13 MarkupSafe<2.1 -jinja2==3.0.3 +jinja2==3.1.4 async-asgi-testclient asgiref -r reqs-base.txt diff --git a/tests/requirements/reqs-base.txt b/tests/requirements/reqs-base.txt index 42bac1bb8..4f79a5929 100644 --- a/tests/requirements/reqs-base.txt +++ b/tests/requirements/reqs-base.txt @@ -8,7 +8,7 @@ coverage[toml]==6.3 ; python_version == '3.7' coverage==7.3.1 ; python_version > '3.7' pytest-cov==4.0.0 ; python_version < '3.8' pytest-cov==4.1.0 ; python_version > '3.7' -jinja2==3.1.2 ; python_version == '3.7' +jinja2==3.1.4 ; python_version == '3.7' pytest-localserver==0.5.0 pytest-mock==3.6.1 ; python_version == '3.6' pytest-mock==3.10.0 ; python_version > '3.6' @@ -29,7 +29,8 @@ mock pytz ecs_logging structlog -wrapt>=1.14.1,<1.15.0 +wrapt>=1.14.1,!=1.15.0 +simplejson pytest-asyncio==0.21.0 ; python_version >= '3.7' asynctest==0.13.0 ; python_version >= '3.7' diff --git a/tests/requirements/reqs-celery-4-django-1.11.txt b/tests/requirements/reqs-celery-4-django-1.11.txt deleted file mode 100644 index 4440bb70f..000000000 --- a/tests/requirements/reqs-celery-4-django-1.11.txt +++ /dev/null @@ -1,2 +0,0 @@ --r reqs-celery-4.txt --r reqs-django-1.11.txt diff --git a/tests/requirements/reqs-celery-4-django-2.0.txt b/tests/requirements/reqs-celery-4-django-2.0.txt deleted file mode 100644 index 72e805f38..000000000 --- a/tests/requirements/reqs-celery-4-django-2.0.txt +++ /dev/null @@ -1,2 +0,0 @@ --r reqs-celery-4.txt --r reqs-django-2.0.txt diff --git a/tests/requirements/reqs-celery-4-flask-1.0.txt b/tests/requirements/reqs-celery-4-flask-1.0.txt deleted file mode 100644 index e357a036f..000000000 --- a/tests/requirements/reqs-celery-4-flask-1.0.txt +++ /dev/null @@ -1,2 +0,0 @@ --r reqs-celery-4.txt --r reqs-flask-1.0.txt diff --git a/tests/requirements/reqs-celery-4.txt b/tests/requirements/reqs-celery-4.txt deleted file mode 100644 index 57ba4c638..000000000 --- a/tests/requirements/reqs-celery-4.txt +++ /dev/null @@ -1,4 +0,0 @@ -celery>4.0,<5 -# including future as it was missing in celery 4.4.4, see https://github.com/celery/celery/issues/6145 -future>=0.18.0 -importlib-metadata<5.0; python_version<"3.8" diff --git a/tests/requirements/reqs-celery-5-django-5.txt b/tests/requirements/reqs-celery-5-django-5.txt new file mode 100644 index 000000000..b528dcb85 --- /dev/null +++ b/tests/requirements/reqs-celery-5-django-5.txt @@ -0,0 +1,2 @@ +-r reqs-celery-5.txt +-r reqs-django-5.0.txt diff --git a/tests/requirements/reqs-django-4.2.txt b/tests/requirements/reqs-django-4.2.txt new file mode 100644 index 000000000..6818ea895 --- /dev/null +++ b/tests/requirements/reqs-django-4.2.txt @@ -0,0 +1,3 @@ +Django>=4.2,<5.0 +jinja2<4 +-r reqs-base.txt diff --git a/tests/requirements/reqs-django-5.0.txt b/tests/requirements/reqs-django-5.0.txt new file mode 100644 index 000000000..dd2e1cea6 --- /dev/null +++ b/tests/requirements/reqs-django-5.0.txt @@ -0,0 +1,3 @@ +Django>=5.0,<5.1 +jinja2<4 +-r reqs-base.txt diff --git a/tests/requirements/reqs-flask-1.1.txt b/tests/requirements/reqs-flask-1.1.txt index 107d375d5..cd32a4696 100644 --- a/tests/requirements/reqs-flask-1.1.txt +++ b/tests/requirements/reqs-flask-1.1.txt @@ -1,4 +1,4 @@ -jinja2<3.1.0 +jinja2<3.2.0 Werkzeug<2.1.0 Flask>=1.1,<1.2 MarkupSafe<2.1 diff --git a/tests/requirements/reqs-flask-2.0.txt b/tests/requirements/reqs-flask-2.0.txt index d68be1afa..dc3cb6572 100644 --- a/tests/requirements/reqs-flask-2.0.txt +++ b/tests/requirements/reqs-flask-2.0.txt @@ -1,4 +1,5 @@ -Flask>=2.0,<3 +Flask>=2.0,<2.1 +Werkzeug<3 blinker>=1.1 itsdangerous -r reqs-base.txt diff --git a/tests/requirements/reqs-flask-2.1.txt b/tests/requirements/reqs-flask-2.1.txt new file mode 100644 index 000000000..84d89b8b9 --- /dev/null +++ b/tests/requirements/reqs-flask-2.1.txt @@ -0,0 +1,4 @@ +Flask>=2.1,<2.2 +blinker>=1.1 +itsdangerous +-r reqs-base.txt diff --git a/tests/requirements/reqs-flask-2.2.txt b/tests/requirements/reqs-flask-2.2.txt new file mode 100644 index 000000000..0b244a851 --- /dev/null +++ b/tests/requirements/reqs-flask-2.2.txt @@ -0,0 +1,4 @@ +Flask>=2.2,<2.3 +blinker>=1.1 +itsdangerous +-r reqs-base.txt diff --git a/tests/requirements/reqs-flask-2.3.txt b/tests/requirements/reqs-flask-2.3.txt new file mode 100644 index 000000000..08434994e --- /dev/null +++ b/tests/requirements/reqs-flask-2.3.txt @@ -0,0 +1,4 @@ +Flask>=2.3,<3 +blinker>=1.1 +itsdangerous +-r reqs-base.txt diff --git a/tests/requirements/reqs-flask-3.0.txt b/tests/requirements/reqs-flask-3.0.txt new file mode 100644 index 000000000..92120aa14 --- /dev/null +++ b/tests/requirements/reqs-flask-3.0.txt @@ -0,0 +1,3 @@ +Flask>=3.0,<3.1 +itsdangerous +-r reqs-base.txt diff --git a/tests/requirements/reqs-starlette-0.13.txt b/tests/requirements/reqs-starlette-0.13.txt index 3144bf464..43d814c60 100644 --- a/tests/requirements/reqs-starlette-0.13.txt +++ b/tests/requirements/reqs-starlette-0.13.txt @@ -1,4 +1,5 @@ starlette>=0.13,<0.14 aiofiles==0.7.0 -requests==2.31.0 +requests==2.32.1; python_version >= '3.8' +requests==2.31.0; python_version < '3.8' -r reqs-base.txt diff --git a/tests/requirements/reqs-starlette-0.14.txt b/tests/requirements/reqs-starlette-0.14.txt index e1952d09b..52ea93114 100644 --- a/tests/requirements/reqs-starlette-0.14.txt +++ b/tests/requirements/reqs-starlette-0.14.txt @@ -1,4 +1,5 @@ starlette>=0.14,<0.15 -requests==2.31.0 +requests==2.32.1; python_version >= '3.8' +requests==2.31.0; python_version < '3.8' aiofiles -r reqs-base.txt diff --git a/tests/scripts/docker/run_tests.sh b/tests/scripts/docker/run_tests.sh index 9a89f93be..de518dc32 100755 --- a/tests/scripts/docker/run_tests.sh +++ b/tests/scripts/docker/run_tests.sh @@ -2,7 +2,7 @@ set -ex function cleanup { - PYTHON_VERSION=${1} docker-compose down -v + PYTHON_VERSION=${1} docker compose down -v if [[ $CODECOV_TOKEN ]]; then cd .. @@ -42,7 +42,7 @@ echo "Running tests for ${1}/${2}" if [[ -n $DOCKER_DEPS ]] then - PYTHON_VERSION=${1} docker-compose up -d ${DOCKER_DEPS} + PYTHON_VERSION=${1} docker compose up -d ${DOCKER_DEPS} fi # CASS_DRIVER_NO_EXTENSIONS is set so we don't build the Cassandra C-extensions, @@ -57,7 +57,7 @@ if ! ${CI}; then . fi -PYTHON_VERSION=${1} docker-compose run \ +PYTHON_VERSION=${1} docker compose run \ -e PYTHON_FULL_VERSION=${1} \ -e LOCAL_USER_ID=$LOCAL_USER_ID \ -e LOCAL_GROUP_ID=$LOCAL_GROUP_ID \ diff --git a/tests/scripts/license_headers_check.sh b/tests/scripts/license_headers_check.sh index dc239df96..9ba9655e0 100755 --- a/tests/scripts/license_headers_check.sh +++ b/tests/scripts/license_headers_check.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash if [[ $# -eq 0 ]] then - FILES=$(find . -iname "*.py" -not -path "./elasticapm/utils/wrapt/*" -not -path "./dist/*" -not -path "./build/*" -not -path "./tests/utils/stacks/linenos.py") + FILES=$(find . -iname "*.py" -not -path "./dist/*" -not -path "./build/*" -not -path "./tests/utils/stacks/linenos.py") else FILES=$@ fi diff --git a/tests/scripts/run_tests.sh b/tests/scripts/run_tests.sh index fc248949f..7fcc85010 100755 --- a/tests/scripts/run_tests.sh +++ b/tests/scripts/run_tests.sh @@ -3,7 +3,7 @@ set -e export PATH=${HOME}/.local/bin:${PATH} -python -m pip install --user -U pip --cache-dir "${PIP_CACHE}" +python -m pip install --user -U pip setuptools --cache-dir "${PIP_CACHE}" python -m pip install --user -r "tests/requirements/reqs-${FRAMEWORK}.txt" --cache-dir "${PIP_CACHE}" export PYTHON_VERSION=$(python -c "import platform; pv=platform.python_version_tuple(); print('pypy' + ('' if pv[0] == 2 else str(pv[0])) if platform.python_implementation() == 'PyPy' else '.'.join(map(str, platform.python_version_tuple()[:2])))") diff --git a/tests/transports/test_urllib3.py b/tests/transports/test_urllib3.py index 42a21c1e9..32a5b7384 100644 --- a/tests/transports/test_urllib3.py +++ b/tests/transports/test_urllib3.py @@ -30,6 +30,7 @@ import os +import time import certifi import mock @@ -115,38 +116,46 @@ def test_generic_error(mock_urlopen, elasticapm_client): def test_http_proxy_environment_variable(elasticapm_client): - with mock.patch.dict("os.environ", {"HTTP_PROXY": "http://example.com"}): + with mock.patch.dict("os.environ", {"HTTP_PROXY": "http://example.com"}, clear=True): transport = Transport("http://localhost:9999", client=elasticapm_client) assert isinstance(transport.http, urllib3.ProxyManager) def test_https_proxy_environment_variable(elasticapm_client): - with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com"}): + with mock.patch.dict( + "os.environ", + { + "HTTPS_PROXY": "https://example.com", + }, + clear=True, + ): transport = Transport("http://localhost:9999", client=elasticapm_client) assert isinstance(transport.http, urllib3.poolmanager.ProxyManager) def test_https_proxy_environment_variable_is_preferred(elasticapm_client): - with mock.patch.dict("os.environ", {"https_proxy": "https://example.com", "HTTP_PROXY": "http://example.com"}): + with mock.patch.dict( + "os.environ", {"https_proxy": "https://example.com", "HTTP_PROXY": "http://example.com"}, clear=True + ): transport = Transport("http://localhost:9999", client=elasticapm_client) assert isinstance(transport.http, urllib3.poolmanager.ProxyManager) assert transport.http.proxy.scheme == "https" def test_no_proxy_star(elasticapm_client): - with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "*"}): + with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "*"}, clear=True): transport = Transport("http://localhost:9999", client=elasticapm_client) assert not isinstance(transport.http, urllib3.poolmanager.ProxyManager) def test_no_proxy_host(elasticapm_client): - with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "localhost"}): + with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "localhost"}, clear=True): transport = Transport("http://localhost:9999", client=elasticapm_client) assert not isinstance(transport.http, urllib3.poolmanager.ProxyManager) def test_no_proxy_all(elasticapm_client): - with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "*"}): + with mock.patch.dict("os.environ", {"HTTPS_PROXY": "https://example.com", "NO_PROXY": "*"}, clear=True): transport = Transport("http://localhost:9999", client=elasticapm_client) assert not isinstance(transport.http, urllib3.poolmanager.ProxyManager) @@ -509,3 +518,90 @@ def test_fetch_server_info_flat_string(waiting_httpserver, caplog, elasticapm_cl transport.fetch_server_info() assert elasticapm_client.server_version is None assert_any_record_contains(caplog.records, "No version key found in server response") + + +def test_close(waiting_httpserver, elasticapm_client): + elasticapm_client.server_version = (8, 0, 0) # avoid making server_info request + waiting_httpserver.serve_content(code=202, content="", headers={"Location": "http://example.com/foo"}) + transport = Transport( + waiting_httpserver.url, client=elasticapm_client, headers=elasticapm_client._transport._headers + ) + transport.start_thread() + + transport.close() + + assert transport._closed is True + assert transport._flushed.is_set() is True + + +def test_close_does_nothing_if_called_from_another_pid(waiting_httpserver, caplog, elasticapm_client): + elasticapm_client.server_version = (8, 0, 0) # avoid making server_info request + waiting_httpserver.serve_content(code=202, content="", headers={"Location": "http://example.com/foo"}) + transport = Transport( + waiting_httpserver.url, client=elasticapm_client, headers=elasticapm_client._transport._headers + ) + transport.start_thread() + + with mock.patch("os.getpid") as getpid_mock: + getpid_mock.return_value = 0 + transport.close() + + assert transport._closed is False + + transport.close() + + +def test_close_can_be_called_multiple_times(waiting_httpserver, caplog, elasticapm_client): + elasticapm_client.server_version = (8, 0, 0) # avoid making server_info request + waiting_httpserver.serve_content(code=202, content="", headers={"Location": "http://example.com/foo"}) + transport = Transport( + waiting_httpserver.url, client=elasticapm_client, headers=elasticapm_client._transport._headers + ) + transport.start_thread() + + with caplog.at_level("INFO", logger="elasticapm.transport.http"): + transport.close() + + assert transport._closed is True + + transport.close() + + +def test_close_timeout_error_without_flushing(waiting_httpserver, caplog, elasticapm_client): + elasticapm_client.server_version = (8, 0, 0) # avoid making server_info request + waiting_httpserver.serve_content(code=202, content="", headers={"Location": "http://example.com/foo"}) + + with caplog.at_level("INFO", logger="elasticapm.transport.http"): + with mock.patch.object(Transport, "_max_flush_time_seconds", 0): + with mock.patch.object(Transport, "_flush") as flush_mock: + # sleep more that the timeout + flush_mock.side_effect = lambda x: time.sleep(0.1) + transport = Transport( + waiting_httpserver.url, client=elasticapm_client, headers=elasticapm_client._transport._headers + ) + transport.start_thread() + # need to write something to the buffer to have _flush() called + transport.queue("error", {"an": "error"}) + transport.close() + + assert transport._flushed.is_set() is False + assert transport._closed is True + record = caplog.records[-1] + assert "Closing the transport connection timed out." in record.msg + + +def test_http_pool_manager_is_recycled_at_stop_thread(waiting_httpserver, caplog, elasticapm_client): + elasticapm_client.server_version = (8, 0, 0) # avoid making server_info request + waiting_httpserver.serve_content(code=202, content="", headers={"Location": "http://example.com/foo"}) + transport = Transport( + waiting_httpserver.url, client=elasticapm_client, headers=elasticapm_client._transport._headers + ) + transport.start_thread() + pool_manager = transport.http + + with caplog.at_level("INFO", logger="elasticapm.transport.http"): + transport.stop_thread() + + assert transport._flushed.is_set() is True + assert pool_manager != transport._http + assert not caplog.records diff --git a/tests/upstream/json-specs/metadata.json b/tests/upstream/json-specs/metadata.json index 7103bbeb5..1122ed68c 100644 --- a/tests/upstream/json-specs/metadata.json +++ b/tests/upstream/json-specs/metadata.json @@ -441,6 +441,14 @@ ], "maxLength": 1024 }, + "host_id": { + "description": "The OpenTelemetry semantic conventions compliant \"host.id\" attribute, if available.", + "type": [ + "null", + "string" + ], + "maxLength": 1024 + }, "hostname": { "description": "Deprecated: Use ConfiguredHostname and DetectedHostname instead. DeprecatedHostname is the host name of the system the service is running on. It does not distinguish between configured and detected hostname and therefore is deprecated and only used if no other hostname information is available.", "type": [ diff --git a/tests/utils/json_utils/tests.py b/tests/utils/json_utils/tests.py index 7cbef4b36..28791e79d 100644 --- a/tests/utils/json_utils/tests.py +++ b/tests/utils/json_utils/tests.py @@ -36,6 +36,8 @@ import decimal import uuid +import pytest + from elasticapm.utils import json_encoder as json @@ -69,6 +71,11 @@ def test_decimal(): assert json.dumps(res) == "1.0" +@pytest.mark.parametrize("res", [float("nan"), float("+inf"), float("-inf")]) +def test_float_invalid_json(res): + assert json.dumps(res) != "null" + + def test_unsupported(): res = object() assert json.dumps(res).startswith('"