Skip to content

Commit

Permalink
feat(cloud): Adding Cloud Resource Context (#1882)
Browse files Browse the repository at this point in the history
* Initial version of getting cloud context from AWS and GCP.
  • Loading branch information
antonpirker committed Feb 27, 2023
1 parent 2d24560 commit 5306eab
Show file tree
Hide file tree
Showing 5 changed files with 740 additions and 0 deletions.
73 changes: 73 additions & 0 deletions .github/workflows/test-integration-cloud_resource_context.yml
Original file line number Diff line number Diff line change
@@ -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
258 changes: 258 additions & 0 deletions sentry_sdk/integrations/cloud_resource_context.py
Original file line number Diff line number Diff line change
@@ -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,
}
Empty file.

0 comments on commit 5306eab

Please sign in to comment.