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:
antonpirker: This is the cloud context definition in relay: https://github.com/getsentry/relay/pull/1854

Patch:
diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py
new file mode 100644
index 00000000..c7b96c35
--- /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/tox.ini b/tox.ini
index 87127690..45facf42 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


Test patch:
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 00000000..d4e2a25b
--- /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/tests/integrations/cloud_resource_context/__init__.py b/tests/integrations/cloud_resource_context/__init__.py
new file mode 100644
index 00000000..e69de29b
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 00000000..b1efd97f
--- /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()