From 5306eabd394079cdff04cd34e64cf2141b53b5a6 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Mon, 27 Feb 2023 09:56:47 +0100 Subject: [PATCH] feat(cloud): Adding Cloud Resource Context (#1882) * Initial version of getting cloud context from AWS and GCP. --- ...est-integration-cloud_resource_context.yml | 73 ++++ .../integrations/cloud_resource_context.py | 258 +++++++++++ .../cloud_resource_context/__init__.py | 0 .../test_cloud_resource_context.py | 405 ++++++++++++++++++ tox.ini | 4 + 5 files changed, 740 insertions(+) create mode 100644 .github/workflows/test-integration-cloud_resource_context.yml create mode 100644 sentry_sdk/integrations/cloud_resource_context.py create mode 100644 tests/integrations/cloud_resource_context/__init__.py create mode 100644 tests/integrations/cloud_resource_context/test_cloud_resource_context.py diff --git a/.github/workflows/test-integration-cloud_resource_context.yml b/.github/workflows/test-integration-cloud_resource_context.yml new file mode 100644 index 0000000000..d4e2a25be8 --- /dev/null +++ b/.github/workflows/test-integration-cloud_resource_context.yml @@ -0,0 +1,73 @@ +name: Test cloud_resource_context + +on: + push: + branches: + - master + - release/** + + pull_request: + +# Cancel in progress workflows on pull_requests. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + +env: + BUILD_CACHE_KEY: ${{ github.sha }} + CACHED_BUILD_PATHS: | + ${{ github.workspace }}/dist-serverless + +jobs: + test: + name: cloud_resource_context, python ${{ matrix.python-version }}, ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + + strategy: + fail-fast: false + matrix: + python-version: ["3.6","3.7","3.8","3.9","3.10","3.11"] + # python3.6 reached EOL and is no longer being supported on + # new versions of hosted runners on Github Actions + # ubuntu-20.04 is the last version that supported python3.6 + # see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877 + os: [ubuntu-20.04] + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Setup Test Env + run: | + pip install codecov "tox>=3,<4" + + - name: Test cloud_resource_context + timeout-minutes: 45 + shell: bash + run: | + set -x # print commands that are executed + coverage erase + + ./scripts/runtox.sh "${{ matrix.python-version }}-cloud_resource_context" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch + coverage combine .coverage* + coverage xml -i + codecov --file coverage.xml + + check_required_tests: + name: All cloud_resource_context tests passed or skipped + needs: test + # Always run this, even if a dependent job failed + if: always() + runs-on: ubuntu-20.04 + steps: + - name: Check for failures + if: contains(needs.test.result, 'failure') + run: | + echo "One of the dependent jobs have failed. You may need to re-run it." && exit 1 diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py new file mode 100644 index 0000000000..c7b96c35a8 --- /dev/null +++ b/sentry_sdk/integrations/cloud_resource_context.py @@ -0,0 +1,258 @@ +import json +import urllib3 # type: ignore + +from sentry_sdk.integrations import Integration +from sentry_sdk.api import set_context +from sentry_sdk.utils import logger + +from sentry_sdk._types import MYPY + +if MYPY: + from typing import Dict + + +CONTEXT_TYPE = "cloud_resource" + +AWS_METADATA_HOST = "169.254.169.254" +AWS_TOKEN_URL = "http://{}/latest/api/token".format(AWS_METADATA_HOST) +AWS_METADATA_URL = "http://{}/latest/dynamic/instance-identity/document".format( + AWS_METADATA_HOST +) + +GCP_METADATA_HOST = "metadata.google.internal" +GCP_METADATA_URL = "http://{}/computeMetadata/v1/?recursive=true".format( + GCP_METADATA_HOST +) + + +class CLOUD_PROVIDER: # noqa: N801 + """ + Name of the cloud provider. + see https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/cloud/ + """ + + ALIBABA = "alibaba_cloud" + AWS = "aws" + AZURE = "azure" + GCP = "gcp" + IBM = "ibm_cloud" + TENCENT = "tencent_cloud" + + +class CLOUD_PLATFORM: # noqa: N801 + """ + The cloud platform. + see https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/cloud/ + """ + + AWS_EC2 = "aws_ec2" + GCP_COMPUTE_ENGINE = "gcp_compute_engine" + + +class CloudResourceContextIntegration(Integration): + """ + Adds cloud resource context to the Senty scope + """ + + identifier = "cloudresourcecontext" + + cloud_provider = "" + + aws_token = "" + http = urllib3.PoolManager() + + gcp_metadata = None + + def __init__(self, cloud_provider=""): + # type: (str) -> None + CloudResourceContextIntegration.cloud_provider = cloud_provider + + @classmethod + def _is_aws(cls): + # type: () -> bool + try: + r = cls.http.request( + "PUT", + AWS_TOKEN_URL, + headers={"X-aws-ec2-metadata-token-ttl-seconds": "60"}, + ) + + if r.status != 200: + return False + + cls.aws_token = r.data + return True + + except Exception: + return False + + @classmethod + def _get_aws_context(cls): + # type: () -> Dict[str, str] + ctx = { + "cloud.provider": CLOUD_PROVIDER.AWS, + "cloud.platform": CLOUD_PLATFORM.AWS_EC2, + } + + try: + r = cls.http.request( + "GET", + AWS_METADATA_URL, + headers={"X-aws-ec2-metadata-token": cls.aws_token}, + ) + + if r.status != 200: + return ctx + + data = json.loads(r.data.decode("utf-8")) + + try: + ctx["cloud.account.id"] = data["accountId"] + except Exception: + pass + + try: + ctx["cloud.availability_zone"] = data["availabilityZone"] + except Exception: + pass + + try: + ctx["cloud.region"] = data["region"] + except Exception: + pass + + try: + ctx["host.id"] = data["instanceId"] + except Exception: + pass + + try: + ctx["host.type"] = data["instanceType"] + except Exception: + pass + + except Exception: + pass + + return ctx + + @classmethod + def _is_gcp(cls): + # type: () -> bool + try: + r = cls.http.request( + "GET", + GCP_METADATA_URL, + headers={"Metadata-Flavor": "Google"}, + ) + + if r.status != 200: + return False + + cls.gcp_metadata = json.loads(r.data.decode("utf-8")) + return True + + except Exception: + return False + + @classmethod + def _get_gcp_context(cls): + # type: () -> Dict[str, str] + ctx = { + "cloud.provider": CLOUD_PROVIDER.GCP, + "cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE, + } + + try: + if cls.gcp_metadata is None: + r = cls.http.request( + "GET", + GCP_METADATA_URL, + headers={"Metadata-Flavor": "Google"}, + ) + + if r.status != 200: + return ctx + + cls.gcp_metadata = json.loads(r.data.decode("utf-8")) + + try: + ctx["cloud.account.id"] = cls.gcp_metadata["project"]["projectId"] + except Exception: + pass + + try: + ctx["cloud.availability_zone"] = cls.gcp_metadata["instance"][ + "zone" + ].split("/")[-1] + except Exception: + pass + + try: + # only populated in google cloud run + ctx["cloud.region"] = cls.gcp_metadata["instance"]["region"].split("/")[ + -1 + ] + except Exception: + pass + + try: + ctx["host.id"] = cls.gcp_metadata["instance"]["id"] + except Exception: + pass + + except Exception: + pass + + return ctx + + @classmethod + def _get_cloud_provider(cls): + # type: () -> str + if cls._is_aws(): + return CLOUD_PROVIDER.AWS + + if cls._is_gcp(): + return CLOUD_PROVIDER.GCP + + return "" + + @classmethod + def _get_cloud_resource_context(cls): + # type: () -> Dict[str, str] + cloud_provider = ( + cls.cloud_provider + if cls.cloud_provider != "" + else CloudResourceContextIntegration._get_cloud_provider() + ) + if cloud_provider in context_getters.keys(): + return context_getters[cloud_provider]() + + return {} + + @staticmethod + def setup_once(): + # type: () -> None + cloud_provider = CloudResourceContextIntegration.cloud_provider + unsupported_cloud_provider = ( + cloud_provider != "" and cloud_provider not in context_getters.keys() + ) + + if unsupported_cloud_provider: + logger.warning( + "Invalid value for cloud_provider: %s (must be in %s). Falling back to autodetection...", + CloudResourceContextIntegration.cloud_provider, + list(context_getters.keys()), + ) + + context = CloudResourceContextIntegration._get_cloud_resource_context() + if context != {}: + set_context(CONTEXT_TYPE, context) + + +# Map with the currently supported cloud providers +# mapping to functions extracting the context +context_getters = { + CLOUD_PROVIDER.AWS: CloudResourceContextIntegration._get_aws_context, + CLOUD_PROVIDER.GCP: CloudResourceContextIntegration._get_gcp_context, +} diff --git a/tests/integrations/cloud_resource_context/__init__.py b/tests/integrations/cloud_resource_context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/cloud_resource_context/test_cloud_resource_context.py b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py new file mode 100644 index 0000000000..b1efd97f3f --- /dev/null +++ b/tests/integrations/cloud_resource_context/test_cloud_resource_context.py @@ -0,0 +1,405 @@ +import json + +import pytest +import mock +from mock import MagicMock + +from sentry_sdk.integrations.cloud_resource_context import ( + CLOUD_PLATFORM, + CLOUD_PROVIDER, +) + +AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD = { + "accountId": "298817902971", + "architecture": "x86_64", + "availabilityZone": "us-east-1b", + "billingProducts": None, + "devpayProductCodes": None, + "marketplaceProductCodes": None, + "imageId": "ami-00874d747dde344fa", + "instanceId": "i-07d3301297fe0a55a", + "instanceType": "t2.small", + "kernelId": None, + "pendingTime": "2023-02-08T07:54:05Z", + "privateIp": "171.131.65.115", + "ramdiskId": None, + "region": "us-east-1", + "version": "2017-09-30", +} + +try: + # Python 3 + AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES = bytes( + json.dumps(AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD), "utf-8" + ) +except TypeError: + # Python 2 + AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES = bytes( + json.dumps(AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD) + ).encode("utf-8") + +GCP_GCE_EXAMPLE_METADATA_PLAYLOAD = { + "instance": { + "attributes": {}, + "cpuPlatform": "Intel Broadwell", + "description": "", + "disks": [ + { + "deviceName": "tests-cloud-contexts-in-python-sdk", + "index": 0, + "interface": "SCSI", + "mode": "READ_WRITE", + "type": "PERSISTENT-BALANCED", + } + ], + "guestAttributes": {}, + "hostname": "tests-cloud-contexts-in-python-sdk.c.client-infra-internal.internal", + "id": 1535324527892303790, + "image": "projects/debian-cloud/global/images/debian-11-bullseye-v20221206", + "licenses": [{"id": "2853224013536823851"}], + "machineType": "projects/542054129475/machineTypes/e2-medium", + "maintenanceEvent": "NONE", + "name": "tests-cloud-contexts-in-python-sdk", + "networkInterfaces": [ + { + "accessConfigs": [ + {"externalIp": "134.30.53.15", "type": "ONE_TO_ONE_NAT"} + ], + "dnsServers": ["169.254.169.254"], + "forwardedIps": [], + "gateway": "10.188.0.1", + "ip": "10.188.0.3", + "ipAliases": [], + "mac": "42:01:0c:7c:00:13", + "mtu": 1460, + "network": "projects/544954029479/networks/default", + "subnetmask": "255.255.240.0", + "targetInstanceIps": [], + } + ], + "preempted": "FALSE", + "remainingCpuTime": -1, + "scheduling": { + "automaticRestart": "TRUE", + "onHostMaintenance": "MIGRATE", + "preemptible": "FALSE", + }, + "serviceAccounts": {}, + "tags": ["http-server", "https-server"], + "virtualClock": {"driftToken": "0"}, + "zone": "projects/142954069479/zones/northamerica-northeast2-b", + }, + "oslogin": {"authenticate": {"sessions": {}}}, + "project": { + "attributes": {}, + "numericProjectId": 204954049439, + "projectId": "my-project-internal", + }, +} + +try: + # Python 3 + GCP_GCE_EXAMPLE_METADATA_PLAYLOAD_BYTES = bytes( + json.dumps(GCP_GCE_EXAMPLE_METADATA_PLAYLOAD), "utf-8" + ) +except TypeError: + # Python 2 + GCP_GCE_EXAMPLE_METADATA_PLAYLOAD_BYTES = bytes( + json.dumps(GCP_GCE_EXAMPLE_METADATA_PLAYLOAD) + ).encode("utf-8") + + +def test_is_aws_http_error(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + response = MagicMock() + response.status = 405 + + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._is_aws() is False + assert CloudResourceContextIntegration.aws_token == "" + + +def test_is_aws_ok(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + response = MagicMock() + response.status = 200 + response.data = b"something" + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._is_aws() is True + assert CloudResourceContextIntegration.aws_token == b"something" + + CloudResourceContextIntegration.http.request = MagicMock( + side_effect=Exception("Test") + ) + assert CloudResourceContextIntegration._is_aws() is False + + +def test_is_aw_exception(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock( + side_effect=Exception("Test") + ) + + assert CloudResourceContextIntegration._is_aws() is False + + +@pytest.mark.parametrize( + "http_status, response_data, expected_context", + [ + [ + 405, + b"", + { + "cloud.provider": CLOUD_PROVIDER.AWS, + "cloud.platform": CLOUD_PLATFORM.AWS_EC2, + }, + ], + [ + 200, + b"something-but-not-json", + { + "cloud.provider": CLOUD_PROVIDER.AWS, + "cloud.platform": CLOUD_PLATFORM.AWS_EC2, + }, + ], + [ + 200, + AWS_EC2_EXAMPLE_IMDSv2_PAYLOAD_BYTES, + { + "cloud.provider": "aws", + "cloud.platform": "aws_ec2", + "cloud.account.id": "298817902971", + "cloud.availability_zone": "us-east-1b", + "cloud.region": "us-east-1", + "host.id": "i-07d3301297fe0a55a", + "host.type": "t2.small", + }, + ], + ], +) +def test_get_aws_context(http_status, response_data, expected_context): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + response = MagicMock() + response.status = http_status + response.data = response_data + + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._get_aws_context() == expected_context + + +def test_is_gcp_http_error(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + response = MagicMock() + response.status = 405 + response.data = b'{"some": "json"}' + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._is_gcp() is False + assert CloudResourceContextIntegration.gcp_metadata is None + + +def test_is_gcp_ok(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + response = MagicMock() + response.status = 200 + response.data = b'{"some": "json"}' + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._is_gcp() is True + assert CloudResourceContextIntegration.gcp_metadata == {"some": "json"} + + +def test_is_gcp_exception(): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock( + side_effect=Exception("Test") + ) + assert CloudResourceContextIntegration._is_gcp() is False + + +@pytest.mark.parametrize( + "http_status, response_data, expected_context", + [ + [ + 405, + None, + { + "cloud.provider": CLOUD_PROVIDER.GCP, + "cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE, + }, + ], + [ + 200, + b"something-but-not-json", + { + "cloud.provider": CLOUD_PROVIDER.GCP, + "cloud.platform": CLOUD_PLATFORM.GCP_COMPUTE_ENGINE, + }, + ], + [ + 200, + GCP_GCE_EXAMPLE_METADATA_PLAYLOAD_BYTES, + { + "cloud.provider": "gcp", + "cloud.platform": "gcp_compute_engine", + "cloud.account.id": "my-project-internal", + "cloud.availability_zone": "northamerica-northeast2-b", + "host.id": 1535324527892303790, + }, + ], + ], +) +def test_get_gcp_context(http_status, response_data, expected_context): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration.gcp_metadata = None + + response = MagicMock() + response.status = http_status + response.data = response_data + + CloudResourceContextIntegration.http = MagicMock() + CloudResourceContextIntegration.http.request = MagicMock(return_value=response) + + assert CloudResourceContextIntegration._get_gcp_context() == expected_context + + +@pytest.mark.parametrize( + "is_aws, is_gcp, expected_provider", + [ + [False, False, ""], + [False, True, CLOUD_PROVIDER.GCP], + [True, False, CLOUD_PROVIDER.AWS], + [True, True, CLOUD_PROVIDER.AWS], + ], +) +def test_get_cloud_provider(is_aws, is_gcp, expected_provider): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration._is_aws = MagicMock(return_value=is_aws) + CloudResourceContextIntegration._is_gcp = MagicMock(return_value=is_gcp) + + assert CloudResourceContextIntegration._get_cloud_provider() == expected_provider + + +@pytest.mark.parametrize( + "cloud_provider", + [ + CLOUD_PROVIDER.ALIBABA, + CLOUD_PROVIDER.AZURE, + CLOUD_PROVIDER.IBM, + CLOUD_PROVIDER.TENCENT, + ], +) +def test_get_cloud_resource_context_unsupported_providers(cloud_provider): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration._get_cloud_provider = MagicMock( + return_value=cloud_provider + ) + + assert CloudResourceContextIntegration._get_cloud_resource_context() == {} + + +@pytest.mark.parametrize( + "cloud_provider", + [ + CLOUD_PROVIDER.AWS, + CLOUD_PROVIDER.GCP, + ], +) +def test_get_cloud_resource_context_supported_providers(cloud_provider): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration._get_cloud_provider = MagicMock( + return_value=cloud_provider + ) + + assert CloudResourceContextIntegration._get_cloud_resource_context() != {} + + +@pytest.mark.parametrize( + "cloud_provider, cloud_resource_context, warning_called, set_context_called", + [ + ["", {}, False, False], + [CLOUD_PROVIDER.AWS, {}, False, False], + [CLOUD_PROVIDER.GCP, {}, False, False], + [CLOUD_PROVIDER.AZURE, {}, True, False], + [CLOUD_PROVIDER.ALIBABA, {}, True, False], + [CLOUD_PROVIDER.IBM, {}, True, False], + [CLOUD_PROVIDER.TENCENT, {}, True, False], + ["", {"some": "context"}, False, True], + [CLOUD_PROVIDER.AWS, {"some": "context"}, False, True], + [CLOUD_PROVIDER.GCP, {"some": "context"}, False, True], + ], +) +def test_setup_once( + cloud_provider, cloud_resource_context, warning_called, set_context_called +): + from sentry_sdk.integrations.cloud_resource_context import ( + CloudResourceContextIntegration, + ) + + CloudResourceContextIntegration.cloud_provider = cloud_provider + CloudResourceContextIntegration._get_cloud_resource_context = MagicMock( + return_value=cloud_resource_context + ) + + with mock.patch( + "sentry_sdk.integrations.cloud_resource_context.set_context" + ) as fake_set_context: + with mock.patch( + "sentry_sdk.integrations.cloud_resource_context.logger.warning" + ) as fake_warning: + CloudResourceContextIntegration.setup_once() + + if set_context_called: + fake_set_context.assert_called_once_with( + "cloud_resource", cloud_resource_context + ) + else: + fake_set_context.assert_not_called() + + if warning_called: + fake_warning.assert_called_once() + else: + fake_warning.assert_not_called() diff --git a/tox.ini b/tox.ini index 8712769031..45facf42c0 100644 --- a/tox.ini +++ b/tox.ini @@ -52,6 +52,9 @@ envlist = # Chalice {py3.6,py3.7,py3.8}-chalice-v{1.16,1.17,1.18,1.19,1.20} + # Cloud Resource Context + {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11}-cloud_resource_context + # Django # - Django 1.x {py2.7,py3.5}-django-v{1.8,1.9,1.10} @@ -416,6 +419,7 @@ setenv = bottle: TESTPATH=tests/integrations/bottle celery: TESTPATH=tests/integrations/celery chalice: TESTPATH=tests/integrations/chalice + cloud_resource_context: TESTPATH=tests/integrations/cloud_resource_context django: TESTPATH=tests/integrations/django falcon: TESTPATH=tests/integrations/falcon fastapi: TESTPATH=tests/integrations/fastapi