diff --git a/apmtest/env.go b/apmtest/env.go new file mode 100644 index 000000000..4492f1644 --- /dev/null +++ b/apmtest/env.go @@ -0,0 +1,8 @@ +package apmtest + +import "os" + +func init() { + // Disable cloud metadata sniffing by default in tests. + os.Setenv("ELASTIC_APM_CLOUD_PROVIDER", "none") +} diff --git a/config.go b/config.go index 9e402ee6a..10d48130b 100644 --- a/config.go +++ b/config.go @@ -57,6 +57,7 @@ const ( envCentralConfig = "ELASTIC_APM_CENTRAL_CONFIG" envBreakdownMetrics = "ELASTIC_APM_BREAKDOWN_METRICS" envUseElasticTraceparentHeader = "ELASTIC_APM_USE_ELASTIC_TRACEPARENT_HEADER" + envCloudProvider = "ELASTIC_APM_CLOUD_PROVIDER" // NOTE(axw) profiling environment variables are experimental. // They may be removed in a future minor version without being diff --git a/docs/configuration.asciidoc b/docs/configuration.asciidoc index b9754e4de..be22b1d21 100644 --- a/docs/configuration.asciidoc +++ b/docs/configuration.asciidoc @@ -593,3 +593,20 @@ https://www.w3.org/TR/trace-context-1/[W3C Trace Context] specification. When this setting is `true`, the agent will also add the header `elastic-apm-traceparent` for backwards compatibility with older versions of Elastic APM agents. + +[float] +[[config-cloud-provider]] +==== `ELASTIC_APM_CLOUD_PROVIDER` + +[options="header"] +|============ +| Environment | Default | Example +| `ELASTIC_APM_CLOUD_PROVIDER` | `"none"` | `"aws"` +|============ + +This config value allows you to specify which cloud provider should be assumed +for metadata collection. By default, the agent will use trial and error to +automatically collect the cloud metadata. + +Valid options are `"none"`, `"auto"`, `"aws"`, `"gcp"`, and `"azure"` +If this config value is set to `"none"`, then no cloud metadata will be collected. diff --git a/internal/apmcloudutil/aws.go b/internal/apmcloudutil/aws.go new file mode 100644 index 000000000..c407feb5b --- /dev/null +++ b/internal/apmcloudutil/aws.go @@ -0,0 +1,83 @@ +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + + "go.elastic.co/apm/model" +) + +const ( + ec2TokenURL = "http://169.254.169.254/latest/api/token" + ec2MetadataURL = "http://169.254.169.254/latest/dynamic/instance-identity/document" +) + +// See: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html +func getAWSCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + token, err := getAWSToken(ctx, client) + if err != nil { + return err + } + + req, err := http.NewRequest("GET", ec2MetadataURL, nil) + if err != nil { + return err + } + if token != "" { + req.Header.Set("X-aws-ec2-metadata-token", token) + } + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var ec2Metadata struct { + AccountID string `json:"accountId"` + AvailabilityZone string `json:"availabilityZone"` + Region string `json:"region"` + InstanceID string `json:"instanceId"` + InstanceType string `json:"instanceType"` + } + if err := json.NewDecoder(resp.Body).Decode(&ec2Metadata); err != nil { + return err + } + + out.Region = ec2Metadata.Region + out.AvailabilityZone = ec2Metadata.AvailabilityZone + if ec2Metadata.InstanceID != "" { + out.Instance = &model.CloudInstance{ID: ec2Metadata.InstanceID} + } + if ec2Metadata.InstanceType != "" { + out.Machine = &model.CloudMachine{Type: ec2Metadata.InstanceType} + } + if ec2Metadata.AccountID != "" { + out.Account = &model.CloudAccount{ID: ec2Metadata.AccountID} + } + return nil +} + +func getAWSToken(ctx context.Context, client *http.Client) (string, error) { + req, err := http.NewRequest("PUT", ec2TokenURL, nil) + if err != nil { + return "", err + } + req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "300") + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return "", err + } + defer resp.Body.Close() + token, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(token), nil +} diff --git a/internal/apmcloudutil/aws_test.go b/internal/apmcloudutil/aws_test.go new file mode 100644 index 000000000..be0ded241 --- /dev/null +++ b/internal/apmcloudutil/aws_test.go @@ -0,0 +1,80 @@ +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestAWSCloudMetadata(t *testing.T) { + srv, client := newAWSMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, AWS} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "aws", + Region: "us-east-2", + AvailabilityZone: "us-east-2a", + Instance: &model.CloudInstance{ + ID: "i-0ae894a7c1c4f2a75", + }, + Machine: &model.CloudMachine{ + Type: "t2.medium", + }, + Account: &model.CloudAccount{ + ID: "946960629917", + }, + }, out) + } +} + +func newAWSMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest/api/token": + w.Write([]byte("topsecret")) + return + case "/latest/dynamic/instance-identity/document": + token := r.Header.Get("X-Aws-Ec2-Metadata-Token") + if token != "topsecret" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("invalid token")) + return + } + break + default: + w.WriteHeader(http.StatusNotFound) + return + } + + w.Write([]byte(`{ + "accountId": "946960629917", + "architecture": "x86_64", + "availabilityZone": "us-east-2a", + "billingProducts": null, + "devpayProductCodes": null, + "marketplaceProductCodes": null, + "imageId": "ami-07c1207a9d40bc3bd", + "instanceId": "i-0ae894a7c1c4f2a75", + "instanceType": "t2.medium", + "kernelId": null, + "pendingTime": "2020-06-12T17:46:09Z", + "privateIp": "172.31.0.212", + "ramdiskId": null, + "region": "us-east-2", + "version": "2017-09-30" +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("169.254.169.254", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/azure.go b/internal/apmcloudutil/azure.go new file mode 100644 index 000000000..3f47d0ab8 --- /dev/null +++ b/internal/apmcloudutil/azure.go @@ -0,0 +1,61 @@ +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "go.elastic.co/apm/model" +) + +const ( + azureMetadataURL = "http://169.254.169.254/metadata/instance/compute?api-version=2019-08-15" +) + +// See: https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service +func getAzureCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + req, err := http.NewRequest("GET", azureMetadataURL, nil) + if err != nil { + return err + } + req.Header.Set("Metadata", "true") + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var azureMetadata struct { + Location string `json:"location"` + Name string `json:"name"` + ResourceGroupName string `json:"resourceGroupName"` + SubscriptionID string `json:"subscriptionId"` + VMID string `json:"vmId"` + VMSize string `json:"vmSize"` + Zone string `json:"zone"` + } + if err := json.NewDecoder(resp.Body).Decode(&azureMetadata); err != nil { + return err + } + + out.Region = azureMetadata.Location + out.AvailabilityZone = azureMetadata.Zone + if azureMetadata.VMID != "" || azureMetadata.Name != "" { + out.Instance = &model.CloudInstance{ID: azureMetadata.VMID, Name: azureMetadata.Name} + } + if azureMetadata.VMSize != "" { + out.Machine = &model.CloudMachine{Type: azureMetadata.VMSize} + } + if azureMetadata.ResourceGroupName != "" { + out.Project = &model.CloudProject{Name: azureMetadata.ResourceGroupName} + } + if azureMetadata.SubscriptionID != "" { + out.Account = &model.CloudAccount{ID: azureMetadata.SubscriptionID} + } + return nil +} diff --git a/internal/apmcloudutil/azure_test.go b/internal/apmcloudutil/azure_test.go new file mode 100644 index 000000000..c90538666 --- /dev/null +++ b/internal/apmcloudutil/azure_test.go @@ -0,0 +1,63 @@ +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestAzureCloudMetadata(t *testing.T) { + srv, client := newAzureMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, Azure} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "azure", + Region: "westus2", + Instance: &model.CloudInstance{ + ID: "e11ebedc-019d-427f-84dd-56cd4388d3a8", + Name: "basepi-test", + }, + Machine: &model.CloudMachine{ + Type: "Standard_D2s_v3", + }, + Project: &model.CloudProject{ + Name: "basepi-testing", + }, + Account: &model.CloudAccount{ + ID: "7657426d-c4c3-44ac-88a2-3b2cd59e6dba", + }, + }, out) + } +} + +func newAzureMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/metadata/instance/compute" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "location": "westus2", + "name": "basepi-test", + "resourceGroupName": "basepi-testing", + "subscriptionId": "7657426d-c4c3-44ac-88a2-3b2cd59e6dba", + "vmId": "e11ebedc-019d-427f-84dd-56cd4388d3a8", + "vmScaleSetName": "", + "vmSize": "Standard_D2s_v3", + "zone": "" +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("169.254.169.254", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/gcp.go b/internal/apmcloudutil/gcp.go new file mode 100644 index 000000000..ca0faeb78 --- /dev/null +++ b/internal/apmcloudutil/gcp.go @@ -0,0 +1,89 @@ +package apmcloudutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "strconv" + "strings" + + "go.elastic.co/apm/model" +) + +const ( + gcpMetadataURL = "http://metadata.google.internal/computeMetadata/v1/?recursive=true" +) + +// See: https://cloud.google.com/compute/docs/storing-retrieving-metadata +func getGCPCloudMetadata(ctx context.Context, client *http.Client, out *model.Cloud) error { + req, err := http.NewRequest("GET", gcpMetadataURL, nil) + if err != nil { + return err + } + req.Header.Set("Metadata-Flavor", "Google") + + resp, err := client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var gcpMetadata struct { + Instance struct { + // ID may be an integer or a hex string. + ID interface{} `json:"id"` + MachineType string `json:"machineType"` + Name string `json:"name"` + Zone string `json:"zone"` + } `json:"instance"` + Project struct { + NumericProjectID *int `json:"numericProjectId"` + ProjectID string `json:"projectId"` + } `json:"project"` + } + decoder := json.NewDecoder(resp.Body) + decoder.UseNumber() + if err := decoder.Decode(&gcpMetadata); err != nil { + return err + } + + out.Region, out.AvailabilityZone = splitGCPZone(gcpMetadata.Instance.Zone) + if gcpMetadata.Instance.ID != nil || gcpMetadata.Instance.Name != "" { + out.Instance = &model.CloudInstance{ + Name: gcpMetadata.Instance.Name, + } + if gcpMetadata.Instance.ID != nil { + out.Instance.ID = fmt.Sprint(gcpMetadata.Instance.ID) + } + } + if gcpMetadata.Instance.MachineType != "" { + out.Machine = &model.CloudMachine{Type: splitGCPMachineType(gcpMetadata.Instance.MachineType)} + } + if gcpMetadata.Project.NumericProjectID != nil || gcpMetadata.Project.ProjectID != "" { + out.Project = &model.CloudProject{Name: gcpMetadata.Project.ProjectID} + if gcpMetadata.Project.NumericProjectID != nil { + out.Project.ID = strconv.Itoa(*gcpMetadata.Project.NumericProjectID) + } + } + return nil +} + +func splitGCPZone(s string) (region, zone string) { + // Format: "projects/projectnum/zones/zone" + zone = path.Base(s) + if sep := strings.LastIndex(zone, "-"); sep != -1 { + region = zone[:sep] + } + return region, zone +} + +func splitGCPMachineType(s string) string { + // Format: projects/513326162531/machineTypes/n1-standard-1 + return path.Base(s) +} diff --git a/internal/apmcloudutil/gcp_test.go b/internal/apmcloudutil/gcp_test.go new file mode 100644 index 000000000..85bbf3f0e --- /dev/null +++ b/internal/apmcloudutil/gcp_test.go @@ -0,0 +1,108 @@ +package apmcloudutil + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/model" +) + +func TestGCPCloudMetadata(t *testing.T) { + t.Run("gce", func(t *testing.T) { + srv, client := newGCEMetadataServer() + defer srv.Close() + + for _, provider := range []Provider{Auto, GCP} { + var out model.Cloud + var logger testLogger + assert.True(t, provider.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "gcp", + Region: "us-west3", + AvailabilityZone: "us-west3-a", + Instance: &model.CloudInstance{ + ID: "4306570268266786072", + Name: "basepi-test", + }, + Machine: &model.CloudMachine{ + Type: "n1-standard-1", + }, + Project: &model.CloudProject{ + ID: "513326162531", + Name: "elastic-apm", + }, + }, out) + } + }) + + t.Run("cloudrun", func(t *testing.T) { + srv, client := newGoogleCloudRunMetadataServer() + defer srv.Close() + + var out model.Cloud + var logger testLogger + assert.True(t, GCP.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) + assert.Equal(t, model.Cloud{ + Provider: "gcp", + Region: "australia-southeast1", + AvailabilityZone: "australia-southeast1-1", + Instance: &model.CloudInstance{ + ID: "00bf4bf02ddbda278fb9b4d70365018bd18a7d3ea42991e2cb03320b48a72b69b6d3765ff526347d7b8e0934dda4591cb1be3ead93086f0b390187fae88ee7cf8acdae7383", + }, + Project: &model.CloudProject{ + ID: "513326162531", + Name: "elastic-apm", + }, + }, out) + }) +} + +func newGCEMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/computeMetadata/v1/" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "instance": { + "id": 4306570268266786072, + "machineType": "projects/513326162531/machineTypes/n1-standard-1", + "name": "basepi-test", + "zone": "projects/513326162531/zones/us-west3-a" + }, + "project": {"numericProjectId": 513326162531, "projectId": "elastic-apm"} +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("metadata.google.internal", srv.Listener.Addr().String())} + return srv, client +} + +func newGoogleCloudRunMetadataServer() (*httptest.Server, *http.Client) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/computeMetadata/v1/" { + w.WriteHeader(http.StatusNotFound) + return + } + w.Write([]byte(`{ + "instance": { + "id": "00bf4bf02ddbda278fb9b4d70365018bd18a7d3ea42991e2cb03320b48a72b69b6d3765ff526347d7b8e0934dda4591cb1be3ead93086f0b390187fae88ee7cf8acdae7383", + "region":"projects/513326162531/regions/australia-southeast1", + "zone":"projects/513326162531/zones/australia-southeast1-1" + }, + "project": { + "numericProjectId": 513326162531, + "projectId": "elastic-apm" + } +}`)) + })) + + client := &http.Client{Transport: newTargetedRoundTripper("metadata.google.internal", srv.Listener.Addr().String())} + return srv, client +} diff --git a/internal/apmcloudutil/provider.go b/internal/apmcloudutil/provider.go new file mode 100644 index 000000000..ec1e0547d --- /dev/null +++ b/internal/apmcloudutil/provider.go @@ -0,0 +1,88 @@ +package apmcloudutil + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "go.elastic.co/apm/internal/apmlog" + "go.elastic.co/apm/model" +) + +// defaultClient is essentially the same as http.DefaultTransport, except +// that it has a short (100ms) dial timeout to avoid delaying startup. +var defaultClient = &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 100 * time.Millisecond, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + }, +} + +// Provider identifies the cloud provider. +type Provider string + +const ( + Auto Provider = "auto" + AWS Provider = "aws" + Azure Provider = "azure" + GCP Provider = "gcp" + None Provider = "none" +) + +// ParseProvider parses the provider name "s", returning the relevant Provider. +// +// If the provider name is unknown, None will be returned with an error. +func ParseProvider(s string) (Provider, error) { + switch Provider(s) { + case Auto, AWS, Azure, GCP, None: + return Provider(s), nil + } + return None, fmt.Errorf("unknown cloud provider %q", s) +} + +// GetCloudMetadata attempts to fetch cloud metadata for cloud provider p, +// storing it into out and returning a boolean indicating that the metadata +// was successfully retrieved. +// +// It is the caller's responsibility to set a reasonable timeout, to ensure +// requests do not block normal operation in non-cloud environments. +func (p Provider) GetCloudMetadata(ctx context.Context, logger apmlog.Logger, out *model.Cloud) bool { + return p.getCloudMetadata(ctx, defaultClient, logger, out) +} + +func (p Provider) getCloudMetadata(ctx context.Context, client *http.Client, logger apmlog.Logger, out *model.Cloud) bool { + if p == None { + return false + } + for _, provider := range []Provider{AWS, Azure, GCP} { + if p != Auto && p != provider { + continue + } + var err error + switch provider { + case AWS: + err = getAWSCloudMetadata(ctx, client, out) + case Azure: + err = getAzureCloudMetadata(ctx, client, out) + case GCP: + err = getGCPCloudMetadata(ctx, client, out) + } + if err == nil { + out.Provider = string(provider) + return true + } else if p != Auto { + if logger != nil { + logger.Warningf("cloud provider %q specified, but cloud metadata could not be retrieved: %s", p, err) + } + return false + } + } + return false +} diff --git a/internal/apmcloudutil/provider_test.go b/internal/apmcloudutil/provider_test.go new file mode 100644 index 000000000..c2ab003b3 --- /dev/null +++ b/internal/apmcloudutil/provider_test.go @@ -0,0 +1,78 @@ +package apmcloudutil + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + + "go.elastic.co/apm/internal/apmlog" + "go.elastic.co/apm/model" +) + +func TestAutoProviderAllFail(t *testing.T) { + var out model.Cloud + var logger testLogger + client := &http.Client{Transport: newTargetedRoundTripper("", "testing.invalid")} + assert.False(t, Auto.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) +} + +func TestNone(t *testing.T) { + type wrappedRoundTripper struct { + http.RoundTripper + } + var out model.Cloud + var logger testLogger + client := &http.Client{Transport: &wrappedRoundTripper{nil /*panics if called*/}} + assert.False(t, None.getCloudMetadata(context.Background(), client, &logger, &out)) + assert.Zero(t, logger) +} + +// newTargetedRoundTripper returns a net/http.RoundTripper which wraps net/http.DefaultTransport, +// rewriting requests for host to be sent to target, and causing all other requests to fail. +func newTargetedRoundTripper(host, target string) http.RoundTripper { + return &targetedRoundTripper{ + Transport: http.DefaultTransport.(*http.Transport), + host: host, + target: target, + } +} + +type targetedRoundTripper struct { + *http.Transport + host string + target string +} + +func (rt *targetedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != rt.host { + port, err := strconv.Atoi(req.URL.Port()) + if err != nil { + port = 80 + } + return nil, &net.OpError{ + Op: "dial", + Net: "tcp", + Addr: &net.TCPAddr{IP: net.ParseIP(req.URL.Hostname()), Port: port}, + Err: errors.New("connect: no route to host"), + } + } + req.URL.Host = rt.target + return rt.Transport.RoundTrip(req) +} + +type testLogger struct { + apmlog.Logger // panic on unexpected method calls + buf bytes.Buffer +} + +func (tl *testLogger) Warningf(format string, args ...interface{}) { + fmt.Fprintf(&tl.buf, "[warning] "+format, args...) +} diff --git a/tracer.go b/tracer.go index f18e53ae2..f82d9fecd 100644 --- a/tracer.go +++ b/tracer.go @@ -1188,6 +1188,10 @@ func (t *Tracer) encodeRequestMetadata(json *fastjson.Writer) { t.process.MarshalFastJSON(json) json.RawString(`,"service":`) service.MarshalFastJSON(json) + if cloud := getCloudMetadata(); cloud != nil { + json.RawString(`,"cloud":`) + cloud.MarshalFastJSON(json) + } if len(globalLabels) > 0 { json.RawString(`,"labels":`) globalLabels.MarshalFastJSON(json) diff --git a/tracer_test.go b/tracer_test.go index eb527a982..871267cdc 100644 --- a/tracer_test.go +++ b/tracer_test.go @@ -401,6 +401,10 @@ func TestTracerMetadata(t *testing.T) { require.NotNil(t, system.Container) assert.Equal(t, container, system.Container) } + + // Cloud metadata is disabled by apmtest by default. + assert.Equal(t, "none", os.Getenv("ELASTIC_APM_CLOUD_PROVIDER")) + assert.Zero(t, recorder.CloudMetadata()) } func TestTracerKubernetesMetadata(t *testing.T) { diff --git a/utils.go b/utils.go index ae24404e3..7f56be7ec 100644 --- a/utils.go +++ b/utils.go @@ -18,6 +18,7 @@ package apm import ( + "context" "fmt" "math/rand" "os" @@ -26,11 +27,14 @@ import ( "regexp" "runtime" "strings" + "sync" "time" "github.com/pkg/errors" + "go.elastic.co/apm/internal/apmcloudutil" "go.elastic.co/apm/internal/apmhostutil" + "go.elastic.co/apm/internal/apmlog" "go.elastic.co/apm/internal/apmstrings" "go.elastic.co/apm/model" ) @@ -42,6 +46,9 @@ var ( goRuntime = model.Runtime{Name: runtime.Compiler, Version: runtime.Version()} localSystem model.System + cloudMetadataOnce sync.Once + cloudMetadata *model.Cloud + serviceNameInvalidRegexp = regexp.MustCompile("[^" + serviceNameValidClass + "]") labelKeyReplacer = strings.NewReplacer(`.`, `_`, `*`, `_`, `"`, `_`) @@ -160,6 +167,30 @@ func getKubernetesMetadata() *model.Kubernetes { return kubernetes } +func getCloudMetadata() *model.Cloud { + // Querying cloud metadata can block, so we don't fetch it at + // package initialisation time. Instead, we defer until it is + // first requested by the tracer. + cloudMetadataOnce.Do(func() { + logger := apmlog.DefaultLogger + provider := apmcloudutil.Auto + if str := os.Getenv(envCloudProvider); str != "" { + var err error + provider, err = apmcloudutil.ParseProvider(str) + if err != nil && logger != nil { + logger.Warningf("disabling cloud metadata: %s", envCloudProvider, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var out model.Cloud + if provider.GetCloudMetadata(ctx, logger, &out) { + cloudMetadata = &out + } + }) + return cloudMetadata +} + func cleanLabelKey(k string) string { return labelKeyReplacer.Replace(k) }