Skip to content

Commit a249e66

Browse files
authored
Add spec compliant GCE detection (#231)
* Add spec compliant GCE detection * Address host.name comment * Changelog * typo
1 parent 193cba6 commit a249e66

File tree

8 files changed

+416
-0
lines changed

8 files changed

+416
-0
lines changed

opentelemetry-resourcedetector-gcp/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Add spec compliant GCE detection
6+
([#231](https://github.com/GoogleCloudPlatform/opentelemetry-operations-python/pull/231))
7+
58
## Version 1.4.0a0
69

710
Released 2022-12-05

opentelemetry-resourcedetector-gcp/setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ install_requires =
2828
opentelemetry-api ~= 1.0
2929
opentelemetry-sdk ~= 1.0
3030
requests ~= 2.24
31+
# TODO: remove when Python 3.7 is dropped
32+
typing_extensions ~= 4.0
3133

3234
[options.packages.find]
3335
where = src
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from typing import Mapping
16+
17+
from opentelemetry.resourcedetector.gcp_resource_detector import (
18+
_gce,
19+
_metadata,
20+
)
21+
from opentelemetry.sdk.resources import Resource, ResourceDetector
22+
from opentelemetry.util.types import AttributeValue
23+
24+
25+
# TODO: use opentelemetry-semantic-conventions package for these constants once it has
26+
# stabilized. Right now, pinning an unstable version would cause dependency conflicts for
27+
# users so these are copied in.
28+
class ResourceAttributes:
29+
AWS_EC2 = "aws_ec2"
30+
CLOUD_ACCOUNT_ID = "cloud.account.id"
31+
CLOUD_AVAILABILITY_ZONE = "cloud.availability_zone"
32+
CLOUD_PLATFORM_KEY = "cloud.platform"
33+
CLOUD_PROVIDER = "cloud.provider"
34+
CLOUD_REGION = "cloud.region"
35+
GCP_COMPUTE_ENGINE = "gcp_compute_engine"
36+
GCP_KUBERNETES_ENGINE = "gcp_kubernetes_engine"
37+
HOST_ID = "host.id"
38+
HOST_NAME = "host.name"
39+
HOST_TYPE = "host.type"
40+
K8S_CLUSTER_NAME = "k8s.cluster.name"
41+
K8S_CONTAINER_NAME = "k8s.container.name"
42+
K8S_NAMESPACE_NAME = "k8s.namespace.name"
43+
K8S_NODE_NAME = "k8s.node.name"
44+
K8S_POD_NAME = "k8s.pod.name"
45+
SERVICE_INSTANCE_ID = "service.instance.id"
46+
SERVICE_NAME = "service.name"
47+
SERVICE_NAMESPACE = "service.namespace"
48+
49+
50+
class GoogleCloudResourceDetector(ResourceDetector):
51+
def detect(self) -> Resource:
52+
if not _metadata.is_available():
53+
return Resource.get_empty()
54+
55+
if _gce.on_gce():
56+
return _gce_resource()
57+
58+
return Resource.get_empty()
59+
60+
61+
def _gce_resource() -> Resource:
62+
zone_and_region = _gce.availability_zone_and_region()
63+
return _make_resource(
64+
{
65+
ResourceAttributes.CLOUD_PLATFORM_KEY: ResourceAttributes.GCP_COMPUTE_ENGINE,
66+
ResourceAttributes.CLOUD_AVAILABILITY_ZONE: zone_and_region.zone,
67+
ResourceAttributes.CLOUD_REGION: zone_and_region.region,
68+
ResourceAttributes.HOST_TYPE: _gce.host_type(),
69+
ResourceAttributes.HOST_ID: _gce.host_id(),
70+
ResourceAttributes.HOST_NAME: _gce.host_name(),
71+
}
72+
)
73+
74+
75+
def _make_resource(attrs: Mapping[str, AttributeValue]) -> Resource:
76+
return Resource.create(
77+
{
78+
ResourceAttributes.CLOUD_PROVIDER: "gcp",
79+
ResourceAttributes.CLOUD_ACCOUNT_ID: _metadata.get_metadata()[
80+
"project"
81+
]["projectId"],
82+
**attrs,
83+
}
84+
)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
import re
17+
from dataclasses import dataclass
18+
19+
from opentelemetry.resourcedetector.gcp_resource_detector._metadata import (
20+
MetadataAccessException,
21+
get_metadata,
22+
)
23+
24+
# Format described in
25+
# https://cloud.google.com/compute/docs/metadata/default-metadata-values#vm_instance_metadata
26+
_ZONE_REGION_RE = re.compile(
27+
r"projects\/\d+\/zones\/(?P<zone>(?P<region>\w+-\w+)-\w+)"
28+
)
29+
30+
_logger = logging.getLogger(__name__)
31+
32+
33+
def on_gce() -> bool:
34+
try:
35+
get_metadata()["instance"]["machineType"]
36+
except (MetadataAccessException, KeyError):
37+
_logger.debug(
38+
"Could not fetch metadata attribute instance/machineType, "
39+
"assuming not on GCE.",
40+
exc_info=True,
41+
)
42+
return False
43+
return True
44+
45+
46+
def host_type() -> str:
47+
return get_metadata()["instance"]["machineType"]
48+
49+
50+
def host_id() -> str:
51+
return get_metadata()["instance"]["id"]
52+
53+
54+
def host_name() -> str:
55+
return get_metadata()["instance"]["name"]
56+
57+
58+
@dataclass
59+
class ZoneAndRegion:
60+
zone: str
61+
region: str
62+
63+
64+
def availability_zone_and_region() -> ZoneAndRegion:
65+
full_zone = get_metadata()["instance"]["zone"]
66+
match = _ZONE_REGION_RE.search(full_zone)
67+
if not match:
68+
raise Exception(
69+
"zone was not in the expected format: "
70+
f"projects/PROJECT_NUM/zones/COUNTRY-REGION-ZONE. Got {full_zone}"
71+
)
72+
73+
return ZoneAndRegion(
74+
zone=match.group("zone"), region=match.group("region")
75+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import logging
16+
from functools import lru_cache
17+
18+
import requests
19+
20+
# TODO: remove when Python 3.7 is dropped
21+
from typing_extensions import TypedDict
22+
23+
_GCP_METADATA_URL = "http://metadata.google.internal/computeMetadata/v1/"
24+
_INSTANCE = "instance"
25+
_RECURSIVE_PARAMS = {"recursive": "true"}
26+
_GCP_METADATA_URL_HEADER = {"Metadata-Flavor": "Google"}
27+
_TIMEOUT_SEC = 5
28+
29+
_logger = logging.getLogger(__name__)
30+
31+
32+
class Project(TypedDict):
33+
projectId: str
34+
35+
36+
class Instance(TypedDict):
37+
id: str
38+
machineType: str
39+
name: str
40+
zone: str
41+
42+
43+
class Metadata(TypedDict):
44+
instance: Instance
45+
project: Project
46+
47+
48+
class MetadataAccessException(Exception):
49+
pass
50+
51+
52+
@lru_cache(maxsize=None)
53+
def get_metadata() -> Metadata:
54+
"""Get all instance and project metadata from the metadata server
55+
56+
Cached for the lifetime of the process.
57+
"""
58+
try:
59+
all_metadata = requests.get(
60+
f"{_GCP_METADATA_URL}",
61+
params=_RECURSIVE_PARAMS,
62+
headers=_GCP_METADATA_URL_HEADER,
63+
timeout=_TIMEOUT_SEC,
64+
).json()
65+
except requests.RequestException as err:
66+
raise MetadataAccessException() from err
67+
68+
return all_metadata
69+
70+
71+
@lru_cache(maxsize=None)
72+
def is_available() -> bool:
73+
try:
74+
requests.get(
75+
f"{_GCP_METADATA_URL}{_INSTANCE}/",
76+
headers=_GCP_METADATA_URL_HEADER,
77+
timeout=_TIMEOUT_SEC,
78+
).raise_for_status()
79+
except requests.RequestException:
80+
_logger.debug(
81+
"Failed to make request to metadata server, assuming it's not available",
82+
exc_info=True,
83+
)
84+
return False
85+
return True
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# name: test_detects_empty_as_fallback
2+
dict({
3+
})
4+
# ---
5+
# name: test_detects_empty_when_not_available
6+
dict({
7+
})
8+
# ---
9+
# name: test_detects_gce
10+
dict({
11+
'cloud.account.id': 'fakeProject',
12+
'cloud.availability_zone': 'us-east4-b',
13+
'cloud.platform': 'gcp_compute_engine',
14+
'cloud.provider': 'gcp',
15+
'cloud.region': 'us-east4',
16+
'host.id': 'fakeId',
17+
'host.name': 'fakeName',
18+
'host.type': 'fakeMachineType',
19+
'service.name': 'unknown_service',
20+
'telemetry.sdk.language': 'python',
21+
'telemetry.sdk.name': 'opentelemetry',
22+
'telemetry.sdk.version': '1.15.0',
23+
})
24+
# ---
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2023 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from unittest.mock import Mock
16+
17+
import pytest
18+
import requests
19+
from opentelemetry.resourcedetector.gcp_resource_detector import _metadata
20+
from opentelemetry.resourcedetector.gcp_resource_detector._detector import (
21+
GoogleCloudResourceDetector,
22+
)
23+
24+
25+
@pytest.fixture(autouse=True)
26+
def fixture_reset_cache():
27+
yield
28+
_metadata.get_metadata.cache_clear()
29+
_metadata.is_available.cache_clear()
30+
31+
32+
@pytest.fixture(name="fake_get", autouse=True)
33+
def fixture_fake_get(monkeypatch: pytest.MonkeyPatch):
34+
mock = Mock()
35+
monkeypatch.setattr(requests, "get", mock)
36+
return mock
37+
38+
39+
@pytest.fixture(name="fake_metadata", autouse=True)
40+
def fixture_fake_metadata(fake_get: Mock):
41+
json = {"instance": {}, "project": {}}
42+
fake_get().json.return_value = json
43+
return json
44+
45+
46+
def test_detects_empty_when_not_available(snapshot, fake_get: Mock):
47+
fake_get.side_effect = requests.HTTPError()
48+
assert dict(GoogleCloudResourceDetector().detect().attributes) == snapshot
49+
50+
51+
def test_detects_empty_as_fallback(snapshot):
52+
assert dict(GoogleCloudResourceDetector().detect().attributes) == snapshot
53+
54+
55+
def test_detects_gce(snapshot, fake_metadata: _metadata.Metadata):
56+
fake_metadata.update(
57+
{
58+
"project": {"projectId": "fakeProject"},
59+
"instance": {
60+
"name": "fakeName",
61+
"id": "fakeId",
62+
"machineType": "fakeMachineType",
63+
"zone": "projects/233510669999/zones/us-east4-b",
64+
},
65+
}
66+
)
67+
68+
assert dict(GoogleCloudResourceDetector().detect().attributes) == snapshot

0 commit comments

Comments
 (0)