diff --git a/exporter/metric/metric.go b/exporter/metric/metric.go index 6d9f3135e..a76d2476b 100644 --- a/exporter/metric/metric.go +++ b/exporter/metric/metric.go @@ -59,6 +59,7 @@ const ( sendBatchSize = 200 cloudMonitoringMetricDescriptorNameFormat = "workload.googleapis.com/%s" + platformMappingMonitoredResourceKey = "gcp.resource_type" ) // key is used to judge the uniqueness of the record descriptor. @@ -367,6 +368,24 @@ func (attrs *attributes) GetString(key string) (string, bool) { // // https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.monitoredResourceDescriptors func (me *metricExporter) resourceToMonitoredResourcepb(res *resource.Resource) *monitoredrespb.MonitoredResource { + platformMrType, platformMappingRequested := res.Set().Value(platformMappingMonitoredResourceKey) + + // check if platform mapping is requested and possible + if platformMappingRequested && platformMrType.AsString() == me.o.monitoredResourceDescription.mrType { + // assemble attributes required to construct this MR + attributeMap := make(map[string]string) + for expectedLabel := range me.o.monitoredResourceDescription.mrLabels { + value, found := res.Set().Value(attribute.Key(expectedLabel)) + if found { + attributeMap[expectedLabel] = value.AsString() + } + } + return &monitoredrespb.MonitoredResource{ + Type: platformMrType.AsString(), + Labels: attributeMap, + } + } + gmr := resourcemapping.ResourceAttributesToMonitoringMonitoredResource(&attributes{ attrs: attribute.NewSet(res.Attributes()...), }) diff --git a/exporter/metric/metric_test.go b/exporter/metric/metric_test.go index 59634bf59..49e393cd3 100644 --- a/exporter/metric/metric_test.go +++ b/exporter/metric/metric_test.go @@ -281,6 +281,38 @@ func TestDescToMetricType(t *testing.T) { } } +func TestMonitoredResourceDescriptionDeduplicatesLabels(t *testing.T) { + clientOpts := []option.ClientOption{ + option.WithEndpoint("http://fake-endpoint"), + option.WithoutAuthentication(), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials())), + } + + opts := []Option{ + WithProjectID("PROJECT_ID_NOT_REAL"), + WithMonitoringClientOptions(clientOpts...), + WithMetricDescriptorTypeFormatter(formatter), + WithMonitoredResourceDescription("storage_client", []string{"service_instance_id", "host_id", "location", "api", "host_id"}), + } + + exporter, err := New(opts...) + assert.NoError(t, err) + + me := exporter.(*metricExporter) + + expectedMrDescription := MonitoredResourceDescription{ + mrType: "storage_client", + mrLabels: map[string]struct{}{ + "service_instance_id": {}, + "location": {}, + "host_id": {}, + "api": {}, + }, + } + + assert.Equal(t, expectedMrDescription, me.o.monitoredResourceDescription) +} + func TestRecordToMpb(t *testing.T) { metricName := "testing" @@ -477,6 +509,7 @@ func TestResourceToMonitoredResourcepb(t *testing.T) { testCases := []struct { desc string resource *resource.Resource + mrDescription MonitoredResourceDescription expectedLabels map[string]string expectedType string }{ @@ -730,6 +763,125 @@ func TestResourceToMonitoredResourcepb(t *testing.T) { "location": "us-west2", }, }, + { + desc: "Custom Monitored Resource mapped successfully", + resource: resource.NewWithAttributes( + semconv.SchemaURL, + attribute.String("gcp.resource_type", "storage_client"), + attribute.String("service_instance_id", "client_id"), + attribute.String("location", "us-west2"), + attribute.String("host_id", "123"), + attribute.String("custom.attribute", "custom"), + attribute.String("detected.attribute", "detected"), + ), + mrDescription: MonitoredResourceDescription{ + mrType: "storage_client", + mrLabels: map[string]struct{}{ + "service_instance_id": {}, + "location": {}, + "host_id": {}, + }, + }, + expectedType: "storage_client", + expectedLabels: map[string]string{ + "service_instance_id": "client_id", + "location": "us-west2", + "host_id": "123", + }, + }, + { + desc: "Custom MR mapping with insufficient required labels", + resource: resource.NewWithAttributes( + semconv.SchemaURL, + attribute.String("gcp.resource_type", "storage_client"), + attribute.String("location", "us-west2"), + attribute.String("host_id", "123"), + attribute.String("custom.attribute", "custom"), + attribute.String("detected.attribute", "detected"), + ), + mrDescription: MonitoredResourceDescription{ + mrType: "storage_client", + mrLabels: map[string]struct{}{ + "service_instance_id": {}, // missing from OTel resource + "location": {}, + "host_id": {}, + }, + }, + expectedType: "storage_client", + expectedLabels: map[string]string{ + "location": "us-west2", + "host_id": "123", + }, + }, + { + desc: "Custom MR mapping with mismatched MR type", + resource: resource.NewWithAttributes( + semconv.SchemaURL, + attribute.String("gcp.resource_type", "storage_reader_client"), + attribute.String("location", "us-west2"), + attribute.String("host_id", "123"), + attribute.String("custom.attribute", "custom"), + attribute.String("detected.attribute", "detected"), + ), + mrDescription: MonitoredResourceDescription{ + mrType: "storage_client", + mrLabels: map[string]struct{}{ + "service_instance_id": {}, // missing from OTel resource + "location": {}, + "host_id": {}, + }, + }, + expectedType: "generic_node", + expectedLabels: map[string]string{ + "location": "global", + "namespace": "", + "node_id": "", + }, + }, + { + desc: "Custom MR platform mapping with no MonitoredResourceDescription", + resource: resource.NewWithAttributes( + semconv.SchemaURL, + attribute.String("gcp.resource_type", "storage_client"), + attribute.String("service_instance_id", "client_id"), + attribute.String("location", "us-west2"), + attribute.String("host_id", "123"), + attribute.String("custom.attribute", "custom"), + attribute.String("detected.attribute", "detected"), + ), + expectedType: "generic_node", + expectedLabels: map[string]string{ + "location": "global", + "namespace": "", + "node_id": "", + }, + }, + { + desc: "Custom MR platform mapping key not present", + resource: resource.NewWithAttributes( + semconv.SchemaURL, + // gcp.resource_type key is absent from OTEL resource + attribute.String("service_instance_id", "client_id"), + attribute.String("location", "us-west2"), + attribute.String("host_id", "123"), + attribute.String("custom.attribute", "custom"), + attribute.String("detected.attribute", "detected"), + ), + mrDescription: MonitoredResourceDescription{ + mrType: "storage_client", + mrLabels: map[string]struct{}{ + "service_instance_id": {}, + "location": {}, + "host_id": {}, + }, + }, + expectedType: "generic_node", + expectedLabels: map[string]string{ + "location": "global", + "namespace": "", + "node_id": "", + }, + }, } md := &googlemetricpb.MetricDescriptor{ @@ -745,15 +897,16 @@ func TestResourceToMonitoredResourcepb(t *testing.T) { libraryname: "", } - me := &metricExporter{ - o: &options{}, - mdCache: map[key]*googlemetricpb.MetricDescriptor{ - mdkey: md, - }, - } - for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { + me := &metricExporter{ + o: &options{ + monitoredResourceDescription: test.mrDescription, + }, + mdCache: map[key]*googlemetricpb.MetricDescriptor{ + mdkey: md, + }, + } got := me.resourceToMonitoredResourcepb(test.resource) if !reflect.DeepEqual(got.GetLabels(), test.expectedLabels) { t.Errorf("expected: %v, actual: %v", test.expectedLabels, got.GetLabels()) diff --git a/exporter/metric/option.go b/exporter/metric/option.go index 63b23b400..11b96067d 100644 --- a/exporter/metric/option.go +++ b/exporter/metric/option.go @@ -28,6 +28,13 @@ import ( var userAgent = fmt.Sprintf("opentelemetry-go %s; google-cloud-metric-exporter %s", otel.Version(), Version()) +// MonitoredResourceDescription is the struct which holds information required to map OTel resource to specific +// Google Cloud MonitoredResource. +type MonitoredResourceDescription struct { + mrLabels map[string]struct{} + mrType string +} + // Option is function type that is passed to the exporter initialization function. type Option func(*options) @@ -47,6 +54,10 @@ type options struct { // add to metrics as metric labels. By default, it adds service.name, // service.namespace, and service.instance.id. resourceAttributeFilter attribute.Filter + // monitoredResourceDescription sets whether to attempt mapping the OTel Resource to a specific + // Google Cloud Monitored Resource. When provided, the exporter attempts to map only to the provided + // monitored resource type. + monitoredResourceDescription MonitoredResourceDescription // projectID is the identifier of the Cloud Monitoring // project the user is uploading the stats data to. // If not set, this will default to your "Application Default Credentials". @@ -172,3 +183,19 @@ func WithCreateServiceTimeSeries() func(o *options) { o.disableCreateMetricDescriptors = true } } + +// WithMonitoredResourceDescription configures the exporter to attempt to map the OpenTelemetry Resource to the provided +// Google MonitoredResource. The provided mrLabels would be searched for in the OpenTelemetry Resource Attributes and if +// found, would be included in the MonitoredResource labels. +func WithMonitoredResourceDescription(mrType string, mrLabels []string) func(o *options) { + return func(o *options) { + mrLabelSet := make(map[string]struct{}) + for _, label := range mrLabels { + mrLabelSet[label] = struct{}{} + } + o.monitoredResourceDescription = MonitoredResourceDescription{ + mrType: mrType, + mrLabels: mrLabelSet, + } + } +}