Repository: getsentry/sentry-python

Issue:
Add cloud resource context
### Problem Statement

When a user gets an error or transacation (performance event) in Sentry they may not know the specific of where that particular service is hosted. Where possible it would be great to know some contextual information about where that service is hosted to speed up problem solving and eventually deploying a fix


example data
```
cloud:
  provider: aws|gcp|azure
  region: ...
  [account|project]_id: ...
  service: ec2, cloud-functions, ...
  resource: <ARN>
```

### Solution Brainstorm

Add this data using the [Contexts Interface](https://develop.sentry.dev/sdk/event-payloads/contexts/) of the event payload

- [x] Define a standard set of contextual data: 
  - example above
- [ ] determine how to fetch that data from top hosting platforms
  - [x] AWS
  - [x] GCP
  - [ ] Azure
  - [ ] Vercel
  - [ ] Netlify
  - [ ] others?
- [ ] Python SDK should check for this context and populate it in the event when possible
 - [ ] relay changes needed prior to


Future considerations:
- contexts should be searchable in product, changes will be needed in sentry backend so this data can be used to filter issues


Discussion:
Netlify Functions [doesn't support Python](https://www.netlify.com/products/functions/) so we can remove that from the list:

> Netlify Functions are files you write in JavaScript, TypeScript, or Go ...

Some other platforms to consider:

* Heroku
* Render
* Fly.io
Resources (will update as I find more):
* [GCP opentelemetry resource detector](https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/blob/main/opentelemetry-resourcedetector-gcp/src/opentelemetry/resourcedetector/gcp_resource_detector/__init__.py) uses [GCP metadata querying](https://cloud.google.com/compute/docs/metadata/querying-metadata)
AWS has similar: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
In OpenTelemetry they have the `resource` entity, which represent the env that produces a set of telemetry data: https://opentelemetry.io/docs/reference/specification/resource/semantic_conventions/

We can model our solution after this.
Here the PR for develop docs of cloud hosting context: https://github.com/getsentry/develop/pull/825

Resolving patch:
diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py
new file mode 100644
--- /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,
+}


Test patch:
diff --git a/tests/integrations/cloud_resource_context/__init__.py b/tests/integrations/cloud_resource_context/__init__.py
new file mode 100644
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
--- /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()