google.golang.org/grpc@v1.72.2/stats/opentelemetry/csm/pluginoption.go (about)

     1  /*
     2   *
     3   * Copyright 2024 gRPC authors.
     4   *
     5   * Licensed under the Apache License, Version 2.0 (the "License");
     6   * you may not use this file except in compliance with the License.
     7   * You may obtain a copy of the License at
     8   *
     9   *     http://www.apache.org/licenses/LICENSE-2.0
    10   *
    11   * Unless required by applicable law or agreed to in writing, software
    12   * distributed under the License is distributed on an "AS IS" BASIS,
    13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    14   * See the License for the specific language governing permissions and
    15   * limitations under the License.
    16   *
    17   */
    18  
    19  // Package csm contains utilities for Google Cloud Service Mesh observability.
    20  package csm
    21  
    22  import (
    23  	"context"
    24  	"encoding/base64"
    25  	"net/url"
    26  	"os"
    27  
    28  	"google.golang.org/grpc/grpclog"
    29  	"google.golang.org/grpc/metadata"
    30  	"google.golang.org/grpc/stats/opentelemetry/internal"
    31  	"google.golang.org/protobuf/proto"
    32  	"google.golang.org/protobuf/types/known/structpb"
    33  
    34  	"go.opentelemetry.io/contrib/detectors/gcp"
    35  	"go.opentelemetry.io/otel/attribute"
    36  	"go.opentelemetry.io/otel/sdk/resource"
    37  )
    38  
    39  var logger = grpclog.Component("csm-observability-plugin")
    40  
    41  // pluginOption emits CSM Labels from the environment and metadata exchange
    42  // for csm channels and all servers.
    43  //
    44  // Do not use this directly; use newPluginOption instead.
    45  type pluginOption struct {
    46  	// localLabels are the labels that identify the local environment a binary
    47  	// is run in, and will be emitted from the CSM Plugin Option.
    48  	localLabels map[string]string
    49  	// metadataExchangeLabelsEncoded are the metadata exchange labels to be sent
    50  	// as the value of metadata key "x-envoy-peer-metadata" in proto wire format
    51  	// and base 64 encoded. This gets sent out from all the servers running in
    52  	// this process and for csm channels.
    53  	metadataExchangeLabelsEncoded string
    54  }
    55  
    56  // newPluginOption returns a new pluginOption with local labels and metadata
    57  // exchange labels derived from the environment.
    58  func newPluginOption(ctx context.Context) internal.PluginOption {
    59  	localLabels, metadataExchangeLabelsEncoded := constructMetadataFromEnv(ctx)
    60  
    61  	return &pluginOption{
    62  		localLabels:                   localLabels,
    63  		metadataExchangeLabelsEncoded: metadataExchangeLabelsEncoded,
    64  	}
    65  }
    66  
    67  // NewLabelsMD returns a metadata.MD with the CSM labels as an encoded protobuf
    68  // Struct as the value of "x-envoy-peer-metadata".
    69  func (cpo *pluginOption) GetMetadata() metadata.MD {
    70  	return metadata.New(map[string]string{
    71  		metadataExchangeKey: cpo.metadataExchangeLabelsEncoded,
    72  	})
    73  }
    74  
    75  // GetLabels gets the CSM peer labels from the metadata provided. It returns
    76  // "unknown" for labels not found. Labels returned depend on the remote type.
    77  // Additionally, local labels determined at initialization time are appended to
    78  // labels returned, in addition to the optionalLabels provided.
    79  func (cpo *pluginOption) GetLabels(md metadata.MD) map[string]string {
    80  	labels := map[string]string{ // Remote labels if type is unknown (i.e. unset or error processing x-envoy-peer-metadata)
    81  		"csm.remote_workload_type":              "unknown",
    82  		"csm.remote_workload_canonical_service": "unknown",
    83  	}
    84  	// Append the local labels.
    85  	for k, v := range cpo.localLabels {
    86  		labels[k] = v
    87  	}
    88  
    89  	val := md.Get("x-envoy-peer-metadata")
    90  	// This can't happen if corresponding csm client because of proto wire
    91  	// format encoding, but since it is arbitrary off the wire be safe.
    92  	if len(val) != 1 {
    93  		logger.Warningf("length of md values of \"x-envoy-peer-metadata\" is not 1, is %v", len(val))
    94  		return labels
    95  	}
    96  
    97  	protoWireFormat, err := base64.RawStdEncoding.DecodeString(val[0])
    98  	if err != nil {
    99  		logger.Warningf("error base 64 decoding value of \"x-envoy-peer-metadata\": %v", err)
   100  		return labels
   101  	}
   102  
   103  	spb := &structpb.Struct{}
   104  	if err := proto.Unmarshal(protoWireFormat, spb); err != nil {
   105  		logger.Warningf("error unmarshalling value of \"x-envoy-peer-metadata\" into proto: %v", err)
   106  		return labels
   107  	}
   108  
   109  	fields := spb.GetFields()
   110  
   111  	labels["csm.remote_workload_type"] = getFromMetadata("type", fields)
   112  	// The value of “csm.remote_workload_canonical_service” comes from
   113  	// MetadataExchange with the key “canonical_service”. (Note that this should
   114  	// be read even if the remote type is unknown.)
   115  	labels["csm.remote_workload_canonical_service"] = getFromMetadata("canonical_service", fields)
   116  
   117  	// Unset/unknown types, and types that aren't GKE or GCP return early with
   118  	// just local labels, remote_workload_type and
   119  	// remote_workload_canonical_service labels.
   120  	workloadType := labels["csm.remote_workload_type"]
   121  	if workloadType != "gcp_kubernetes_engine" && workloadType != "gcp_compute_engine" {
   122  		return labels
   123  	}
   124  	// GKE and GCE labels.
   125  	labels["csm.remote_workload_project_id"] = getFromMetadata("project_id", fields)
   126  	labels["csm.remote_workload_location"] = getFromMetadata("location", fields)
   127  	labels["csm.remote_workload_name"] = getFromMetadata("workload_name", fields)
   128  	if workloadType == "gcp_compute_engine" {
   129  		return labels
   130  	}
   131  
   132  	// GKE only labels.
   133  	labels["csm.remote_workload_cluster_name"] = getFromMetadata("cluster_name", fields)
   134  	labels["csm.remote_workload_namespace_name"] = getFromMetadata("namespace_name", fields)
   135  	return labels
   136  }
   137  
   138  // getFromMetadata gets the value for the metadata key from the protobuf
   139  // metadata. Returns "unknown" if the metadata is not found in the protobuf
   140  // metadata, or if the value is not a string value. Returns the string value
   141  // otherwise.
   142  func getFromMetadata(metadataKey string, metadata map[string]*structpb.Value) string {
   143  	if metadata != nil {
   144  		if metadataVal, ok := metadata[metadataKey]; ok {
   145  			if _, ok := metadataVal.GetKind().(*structpb.Value_StringValue); ok {
   146  				return metadataVal.GetStringValue()
   147  			}
   148  		}
   149  	}
   150  	return "unknown"
   151  }
   152  
   153  // getFromResource gets the value for the resource key from the attribute set.
   154  // Returns "unknown" if the resourceKey is not found in the attribute set or is
   155  // not a string value, the string value otherwise.
   156  func getFromResource(resourceKey attribute.Key, set *attribute.Set) string {
   157  	if set != nil {
   158  		if resourceVal, ok := set.Value(resourceKey); ok && resourceVal.Type() == attribute.STRING {
   159  			return resourceVal.AsString()
   160  		}
   161  	}
   162  	return "unknown"
   163  }
   164  
   165  // getEnv returns "unknown" if environment variable is unset, the environment
   166  // variable otherwise.
   167  func getEnv(name string) string {
   168  	if val, ok := os.LookupEnv(name); ok {
   169  		return val
   170  	}
   171  	return "unknown"
   172  }
   173  
   174  var (
   175  	// This function will be overridden in unit tests.
   176  	getAttrSetFromResourceDetector = func(ctx context.Context) *attribute.Set {
   177  		r, err := resource.New(ctx, resource.WithFromEnv(), resource.WithDetectors(gcp.NewDetector()))
   178  		if err != nil {
   179  			logger.Warningf("error reading OpenTelemetry resource: %v", err)
   180  		}
   181  		if r != nil {
   182  			// It's possible for resource.New to return partial data alongside
   183  			// an error. In this case, use partial data and set "unknown" for
   184  			// labels missing.
   185  			return r.Set()
   186  		}
   187  		return nil
   188  	}
   189  )
   190  
   191  // constructMetadataFromEnv creates local labels and labels to send to the peer
   192  // using metadata exchange based off resource detection and environment
   193  // variables.
   194  //
   195  // Returns local labels, and base 64 encoded protobuf.Struct containing metadata
   196  // exchange labels.
   197  func constructMetadataFromEnv(ctx context.Context) (map[string]string, string) {
   198  	set := getAttrSetFromResourceDetector(ctx)
   199  
   200  	labels := make(map[string]string)
   201  	labels["type"] = getFromResource("cloud.platform", set)
   202  	labels["canonical_service"] = getEnv("CSM_CANONICAL_SERVICE_NAME")
   203  
   204  	// If type is not GCE or GKE only metadata exchange labels are "type" and
   205  	// "canonical_service".
   206  	cloudPlatformVal := labels["type"]
   207  	if cloudPlatformVal != "gcp_kubernetes_engine" && cloudPlatformVal != "gcp_compute_engine" {
   208  		return initializeLocalAndMetadataLabels(labels)
   209  	}
   210  
   211  	// GCE and GKE labels:
   212  	labels["workload_name"] = getEnv("CSM_WORKLOAD_NAME")
   213  
   214  	locationVal := "unknown"
   215  	if resourceVal, ok := set.Value("cloud.availability_zone"); ok && resourceVal.Type() == attribute.STRING {
   216  		locationVal = resourceVal.AsString()
   217  	} else if resourceVal, ok = set.Value("cloud.region"); ok && resourceVal.Type() == attribute.STRING {
   218  		locationVal = resourceVal.AsString()
   219  	}
   220  	labels["location"] = locationVal
   221  
   222  	labels["project_id"] = getFromResource("cloud.account.id", set)
   223  	if cloudPlatformVal == "gcp_compute_engine" {
   224  		return initializeLocalAndMetadataLabels(labels)
   225  	}
   226  
   227  	// GKE specific labels:
   228  	labels["namespace_name"] = getFromResource("k8s.namespace.name", set)
   229  	labels["cluster_name"] = getFromResource("k8s.cluster.name", set)
   230  	return initializeLocalAndMetadataLabels(labels)
   231  }
   232  
   233  // initializeLocalAndMetadataLabels csm local labels for a CSM Plugin Option to
   234  // record. It also builds out a base 64 encoded protobuf.Struct containing the
   235  // metadata exchange labels to be sent as part of metadata exchange from a CSM
   236  // Plugin Option.
   237  func initializeLocalAndMetadataLabels(labels map[string]string) (map[string]string, string) {
   238  	// The value of “csm.workload_canonical_service” comes from
   239  	// “CSM_CANONICAL_SERVICE_NAME” env var, “unknown” if unset.
   240  	val := labels["canonical_service"]
   241  	localLabels := make(map[string]string)
   242  	localLabels["csm.workload_canonical_service"] = val
   243  	localLabels["csm.mesh_id"] = getEnv("CSM_MESH_ID")
   244  
   245  	// Metadata exchange labels - can go ahead and encode into proto, and then
   246  	// base64.
   247  	pbLabels := &structpb.Struct{
   248  		Fields: map[string]*structpb.Value{},
   249  	}
   250  	for k, v := range labels {
   251  		pbLabels.Fields[k] = structpb.NewStringValue(v)
   252  	}
   253  	protoWireFormat, err := proto.Marshal(pbLabels)
   254  	metadataExchangeLabelsEncoded := ""
   255  	if err == nil {
   256  		metadataExchangeLabelsEncoded = base64.RawStdEncoding.EncodeToString(protoWireFormat)
   257  	}
   258  	// else - This behavior triggers server side to reply (if sent from a gRPC
   259  	// Client within this binary) with the metadata exchange labels. Even if
   260  	// client side has a problem marshaling proto into wire format, it can
   261  	// still use server labels so send an empty string as the value of
   262  	// x-envoy-peer-metadata. The presence of this metadata exchange header
   263  	// will cause server side to respond with metadata exchange labels.
   264  
   265  	return localLabels, metadataExchangeLabelsEncoded
   266  }
   267  
   268  // metadataExchangeKey is the key for HTTP metadata exchange.
   269  const metadataExchangeKey = "x-envoy-peer-metadata"
   270  
   271  func determineTargetCSM(parsedTarget *url.URL) bool {
   272  	// On the client-side, the channel target is used to determine if a channel is a
   273  	// CSM channel or not. CSM channels need to have an “xds” scheme and a
   274  	// "traffic-director-global.xds.googleapis.com" authority. In the cases where no
   275  	// authority is mentioned, the authority is assumed to be CSM. MetadataExchange
   276  	// is performed only for CSM channels. Non-metadata exchange labels are detected
   277  	// as described below.
   278  	return parsedTarget.Scheme == "xds" && (parsedTarget.Host == "" || parsedTarget.Host == "traffic-director-global.xds.googleapis.com")
   279  }