Repository: open-telemetry/opentelemetry-go-contrib (go)

Issue:
Add a resource detector for Azure VMs
### Problem Statement

Currently, there is no resource detector for Azure.

### Proposed Solution

A resource detector for Azure VMs should be provided which sets values according to semantic conventions for [host](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/host.md) and [cloud](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/cloud.md) resources.

### Alternatives
Setting relevant attributes manually without resource detectors.

Pull request description:
Add a resource detector for Azure VMs
Fixes #5421 

This PR adds a resource detector for Azure VMs which sets values according to to semantic conventions for [host](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/host.md), [cloud](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/cloud.md), and [os](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/os.md) resources:

* `cloud.provider`
* `cloud.platform`
* `host.id`
* `host.name`
* `host.type`
* `os.type`
* `os.version`

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d1a5eb2b..4ddc9358b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
 ### Added
 
 - The `go.opentelemetry.io/contrib/config` add support to configure periodic reader interval and timeout. (#5661)
+- Add the new `go.opentelemetry.io/contrib/detectors/azure/azurevm` package to provide a resource detector for Azure VMs. (#5422)
 - Add support to configure views when creating MeterProvider using the config package. (#5654)
 - Add log support for the autoexport package. (#5733)
 - Add support for disabling the old runtime metrics using the `OTEL_GO_X_DEPRECATED_RUNTIME_METRICS=false` environment variable. (#5747)
diff --git a/CODEOWNERS b/CODEOWNERS
index e3d4f0ec3..bb111d307 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -36,6 +36,7 @@ detectors/aws/ec2                                                       @open-te
 detectors/aws/ecs                                                       @open-telemetry/go-approvers @pyohannes @akats7
 detectors/aws/eks                                                       @open-telemetry/go-approvers @pyohannes
 detectors/aws/lambda                                                    @open-telemetry/go-approvers @akats7
+detectors/azure/                                                        @open-telemetry/go-approvers @pyohannes
 detectors/gcp/                                                          @open-telemetry/go-approvers @dashpole
 
 exporters/autoexport                                                    @open-telemetry/go-approvers @MikeGoldsmith @pellared
diff --git a/detectors/azure/azurevm/README.md b/detectors/azure/azurevm/README.md
new file mode 100644
index 000000000..f5b09e28f
--- /dev/null
+++ b/detectors/azure/azurevm/README.md
@@ -0,0 +1,3 @@
+# Azure VM Resource detector
+
+<!--[![PkgGoDev](https://pkg.go.dev/badge/go.opentelemetry.io/contrib/detectors/azure/azurevm)](https://pkg.go.dev/go.opentelemetry.io/contrib/detectors/azure/azurevm)-->
diff --git a/detectors/azure/azurevm/doc.go b/detectors/azure/azurevm/doc.go
new file mode 100644
index 000000000..779068e92
--- /dev/null
+++ b/detectors/azure/azurevm/doc.go
@@ -0,0 +1,25 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+/*
+Package azurevm provides a [resource.Detector] which supports detecting
+attributes specific to Azure VMs.
+
+According to semantic conventions for [host], [cloud], and [os] attributes,
+each of the following attributes is added if it is available:
+
+  - cloud.provider
+  - cloud.platform
+  - cloud.region
+  - cloud.resource_id
+  - host.id
+  - host.name
+  - host.type
+  - os.type
+  - os.version
+
+[host]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/host.md
+[cloud]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/cloud.md
+[os]: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/resource/os.md
+*/
+package azurevm // import "go.opentelemetry.io/contrib/detectors/azure/azurevm"
diff --git a/detectors/azure/azurevm/go.mod b/detectors/azure/azurevm/go.mod
new file mode 100644
index 000000000..569312174
--- /dev/null
+++ b/detectors/azure/azurevm/go.mod
@@ -0,0 +1,20 @@
+module go.opentelemetry.io/contrib/detectors/azure/azurevm
+
+go 1.21
+
+require (
+	github.com/stretchr/testify v1.9.0
+	go.opentelemetry.io/otel v1.27.0
+	go.opentelemetry.io/otel/sdk v1.25.0
+)
+
+require (
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/go-logr/logr v1.4.1 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	go.opentelemetry.io/otel/metric v1.27.0 // indirect
+	go.opentelemetry.io/otel/trace v1.27.0 // indirect
+	golang.org/x/sys v0.18.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/detectors/azure/azurevm/go.sum b/detectors/azure/azurevm/go.sum
new file mode 100644
index 000000000..377a5231a
--- /dev/null
+++ b/detectors/azure/azurevm/go.sum
@@ -0,0 +1,27 @@
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
+github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg=
+go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ=
+go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik=
+go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak=
+go.opentelemetry.io/otel/sdk v1.25.0 h1:PDryEJPC8YJZQSyLY5eqLeafHtG+X7FWnf3aXMtxbqo=
+go.opentelemetry.io/otel/sdk v1.25.0/go.mod h1:oFgzCM2zdsxKzz6zwpTZYLLQsFwc+K0daArPdIhuxkw=
+go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw=
+go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4=
+golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
+golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/detectors/azure/azurevm/vm.go b/detectors/azure/azurevm/vm.go
new file mode 100644
index 000000000..7787427ea
--- /dev/null
+++ b/detectors/azure/azurevm/vm.go
@@ -0,0 +1,114 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package azurevm // import "go.opentelemetry.io/contrib/detectors/azure/azurevm"
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"io"
+	"net/http"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/sdk/resource"
+	semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
+)
+
+const defaultAzureVMMetadataEndpoint = "http://169.254.169.254/metadata/instance/compute?api-version=2021-12-13&format=json"
+
+// ResourceDetector collects resource information of Azure VMs.
+type ResourceDetector struct {
+	endpoint string
+}
+
+type vmMetadata struct {
+	VMId       *string `json:"vmId"`
+	Location   *string `json:"location"`
+	ResourceId *string `json:"resourceId"`
+	Name       *string `json:"name"`
+	VMSize     *string `json:"vmSize"`
+	OsType     *string `json:"osType"`
+	Version    *string `json:"version"`
+}
+
+// New returns a [ResourceDetector] that will detect Azure VM resources.
+func New() *ResourceDetector {
+	return &ResourceDetector{defaultAzureVMMetadataEndpoint}
+}
+
+// Detect detects associated resources when running on an Azure VM.
+func (detector *ResourceDetector) Detect(ctx context.Context) (*resource.Resource, error) {
+	jsonMetadata, runningInAzure, err := detector.getJSONMetadata(ctx)
+	if err != nil {
+		if !runningInAzure {
+			return resource.Empty(), nil
+		}
+
+		return nil, err
+	}
+
+	var metadata vmMetadata
+	err = json.Unmarshal(jsonMetadata, &metadata)
+	if err != nil {
+		return nil, err
+	}
+
+	attributes := []attribute.KeyValue{
+		semconv.CloudProviderAzure,
+		semconv.CloudPlatformAzureVM,
+	}
+
+	if metadata.VMId != nil {
+		attributes = append(attributes, semconv.HostID(*metadata.VMId))
+	}
+	if metadata.Location != nil {
+		attributes = append(attributes, semconv.CloudRegion(*metadata.Location))
+	}
+	if metadata.ResourceId != nil {
+		attributes = append(attributes, semconv.CloudResourceID(*metadata.ResourceId))
+	}
+	if metadata.Name != nil {
+		attributes = append(attributes, semconv.HostName(*metadata.Name))
+	}
+	if metadata.VMSize != nil {
+		attributes = append(attributes, semconv.HostType(*metadata.VMSize))
+	}
+	if metadata.OsType != nil {
+		attributes = append(attributes, semconv.OSTypeKey.String(*metadata.OsType))
+	}
+	if metadata.Version != nil {
+		attributes = append(attributes, semconv.OSVersion(*metadata.Version))
+	}
+
+	return resource.NewWithAttributes(semconv.SchemaURL, attributes...), nil
+}
+
+func (detector *ResourceDetector) getJSONMetadata(ctx context.Context) ([]byte, bool, error) {
+	pTransport := &http.Transport{Proxy: nil}
+
+	client := http.Client{Transport: pTransport}
+
+	req, err := http.NewRequestWithContext(ctx, "GET", detector.endpoint, nil)
+	if err != nil {
+		return nil, false, err
+	}
+
+	req.Header.Add("Metadata", "True")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil, false, err
+	}
+
+	defer resp.Body.Close()
+
+	if resp.StatusCode == http.StatusOK {
+		bytes, err := io.ReadAll(resp.Body)
+		return bytes, true, err
+	}
+
+	runningInAzure := resp.StatusCode < 400 || resp.StatusCode > 499
+
+	return nil, runningInAzure, errors.New(http.StatusText(resp.StatusCode))
+}
diff --git a/versions.yaml b/versions.yaml
index d11eba4e4..26e0311c7 100644
--- a/versions.yaml
+++ b/versions.yaml
@@ -82,6 +82,10 @@ module-sets:
     version: v0.0.1
     modules:
       - go.opentelemetry.io/contrib/processors/baggage/baggagetrace
+  experimental-detectors:
+    version: v0.0.1
+    modules:
+      - go.opentelemetry.io/contrib/detectors/azure/azurevm
 excluded-modules:
   - go.opentelemetry.io/contrib/bridges/otelzap
   - go.opentelemetry.io/contrib/instrgen


Test patch:
diff --git a/detectors/azure/azurevm/example_new_test.go b/detectors/azure/azurevm/example_new_test.go
new file mode 100644
index 000000000..a5f34f34a
--- /dev/null
+++ b/detectors/azure/azurevm/example_new_test.go
@@ -0,0 +1,22 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package azurevm_test
+
+import (
+	"context"
+	"fmt"
+
+	"go.opentelemetry.io/contrib/detectors/azure/azurevm"
+)
+
+func ExampleNew() {
+	azureVMResourceDetector := azurevm.New()
+	resource, err := azureVMResourceDetector.Detect(context.Background())
+	if err != nil {
+		panic(err)
+	}
+
+	// Now, you can use the resource (e.g. pass it to a tracer or meter provider).
+	fmt.Println(resource.SchemaURL())
+}
diff --git a/detectors/azure/azurevm/vm_test.go b/detectors/azure/azurevm/vm_test.go
new file mode 100644
index 000000000..035ca6e31
--- /dev/null
+++ b/detectors/azure/azurevm/vm_test.go
@@ -0,0 +1,114 @@
+// Copyright The OpenTelemetry Authors
+// SPDX-License-Identifier: Apache-2.0
+
+package azurevm
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/sdk/resource"
+	semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
+)
+
+func TestDetect(t *testing.T) {
+	type input struct {
+		jsonMetadata string
+		statusCode   int
+	}
+	type expected struct {
+		resource *resource.Resource
+		err      bool
+	}
+	type testCase struct {
+		input    input
+		expected expected
+	}
+
+	testTable := []testCase{
+		{
+			input: input{
+				jsonMetadata: `{ 
+					"location": "us-west3",
+					"resourceId": "/subscriptions/sid/resourceGroups/rid/providers/pname/name",
+					"vmId": "43f65c49-8715-4639-88a9-be6d7eb749a5",
+					"name": "localhost-3",
+					"vmSize": "Standard_D2s_v3",
+					"osType": "linux",
+					"version": "6.5.0-26-generic"
+				}`,
+				statusCode: http.StatusOK,
+			},
+			expected: expected{
+				resource: resource.NewWithAttributes(semconv.SchemaURL, []attribute.KeyValue{
+					semconv.CloudProviderAzure,
+					semconv.CloudPlatformAzureVM,
+					semconv.CloudRegion("us-west3"),
+					semconv.CloudResourceID("/subscriptions/sid/resourceGroups/rid/providers/pname/name"),
+					semconv.HostID("43f65c49-8715-4639-88a9-be6d7eb749a5"),
+					semconv.HostName("localhost-3"),
+					semconv.HostType("Standard_D2s_v3"),
+					semconv.OSTypeKey.String("linux"),
+					semconv.OSVersion("6.5.0-26-generic"),
+				}...),
+				err: false,
+			},
+		},
+		{
+			input: input{
+				jsonMetadata: `{`,
+				statusCode:   http.StatusOK,
+			},
+			expected: expected{
+				resource: nil,
+				err:      true,
+			},
+		},
+		{
+			input: input{
+				jsonMetadata: "",
+				statusCode:   http.StatusNotFound,
+			},
+			expected: expected{
+				resource: resource.Empty(),
+				err:      false,
+			},
+		},
+		{
+			input: input{
+				jsonMetadata: "",
+				statusCode:   http.StatusInternalServerError,
+			},
+			expected: expected{
+				resource: nil,
+				err:      true,
+			},
+		},
+	}
+
+	for _, tCase := range testTable {
+		svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w.WriteHeader(tCase.input.statusCode)
+
+			if r.Header.Get("Metadata") == "True" {
+				fmt.Fprintf(w, tCase.input.jsonMetadata)
+			}
+		}))
+
+		detector := New()
+		detector.endpoint = svr.URL
+
+		azureResource, err := detector.Detect(context.Background())
+
+		svr.Close()
+
+		assert.Equal(t, err != nil, tCase.expected.err)
+		assert.Equal(t, tCase.expected.resource, azureResource)
+	}
+}