snapshot of https://github.com/grpc/grpc-go.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Stale bot / stale (push) Failing after 1s
Some checks failed
codecov / upload (push) Failing after 13m59s
CodeQL / Analyze (push) Failing after 1s
Testing / vet-proto (push) Failing after 1s
Testing / tests (arm, latest) (push) Waiting to run
Testing / extras (latest) (push) Failing after 1s
Testing / static checks (latest-1) (push) Failing after 1s
Testing / tests (-race, latest) (push) Failing after 1s
Testing / tests (i386, latest) (push) Failing after 1s
Testing / tests (latest) (push) Failing after 1s
Testing / tests (latest-1) (push) Failing after 1s
Lock Threads / lock (push) Failing after 1s
govulncheck / govulncheck (.) (push) Failing after 1s
govulncheck / govulncheck (./cmd/protoc-gen-go-grpc) (push) Failing after 1s
govulncheck / govulncheck (./gcp/observability) (push) Failing after 1s
govulncheck / govulncheck (./security/advancedtls) (push) Failing after 1s
govulncheck / govulncheck (./stats/opencensus) (push) Failing after 1s
Stale bot / stale (push) Failing after 1s
This commit is contained in:
72
stats/handlers.go
Normal file
72
stats/handlers.go
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2016 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// ConnTagInfo defines the relevant information needed by connection context tagger.
|
||||
type ConnTagInfo struct {
|
||||
// RemoteAddr is the remote address of the corresponding connection.
|
||||
RemoteAddr net.Addr
|
||||
// LocalAddr is the local address of the corresponding connection.
|
||||
LocalAddr net.Addr
|
||||
}
|
||||
|
||||
// RPCTagInfo defines the relevant information needed by RPC context tagger.
|
||||
type RPCTagInfo struct {
|
||||
// FullMethodName is the RPC method in the format of /package.service/method.
|
||||
FullMethodName string
|
||||
// FailFast indicates if this RPC is failfast.
|
||||
// This field is only valid on client side, it's always false on server side.
|
||||
FailFast bool
|
||||
// NameResolutionDelay indicates if the RPC needed to wait for the
|
||||
// initial name resolver update before it could begin. This should only
|
||||
// happen if the channel is IDLE when the RPC is started. Note that
|
||||
// all retry or hedging attempts for an RPC that experienced a delay
|
||||
// will have it set.
|
||||
//
|
||||
// This field is only valid on the client side; it is always false on
|
||||
// the server side.
|
||||
NameResolutionDelay bool
|
||||
}
|
||||
|
||||
// Handler defines the interface for the related stats handling (e.g., RPCs, connections).
|
||||
type Handler interface {
|
||||
// TagRPC can attach some information to the given context.
|
||||
// The context used for the rest lifetime of the RPC will be derived from
|
||||
// the returned context.
|
||||
TagRPC(context.Context, *RPCTagInfo) context.Context
|
||||
// HandleRPC processes the RPC stats.
|
||||
HandleRPC(context.Context, RPCStats)
|
||||
|
||||
// TagConn can attach some information to the given context.
|
||||
// The returned context will be used for stats handling.
|
||||
// For conn stats handling, the context used in HandleConn for this
|
||||
// connection will be derived from the context returned.
|
||||
// For RPC stats handling,
|
||||
// - On server side, the context used in HandleRPC for all RPCs on this
|
||||
// connection will be derived from the context returned.
|
||||
// - On client side, the context is not derived from the context returned.
|
||||
TagConn(context.Context, *ConnTagInfo) context.Context
|
||||
// HandleConn processes the Conn stats.
|
||||
HandleConn(context.Context, ConnStats)
|
||||
}
|
||||
81
stats/metrics.go
Normal file
81
stats/metrics.go
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package stats
|
||||
|
||||
import "maps"
|
||||
|
||||
// MetricSet is a set of metrics to record. Once created, MetricSet is immutable,
|
||||
// however Add and Remove can make copies with specific metrics added or
|
||||
// removed, respectively.
|
||||
//
|
||||
// Do not construct directly; use NewMetricSet instead.
|
||||
type MetricSet struct {
|
||||
// metrics are the set of metrics to initialize.
|
||||
metrics map[string]bool
|
||||
}
|
||||
|
||||
// NewMetricSet returns a MetricSet containing metricNames.
|
||||
func NewMetricSet(metricNames ...string) *MetricSet {
|
||||
newMetrics := make(map[string]bool)
|
||||
for _, metric := range metricNames {
|
||||
newMetrics[metric] = true
|
||||
}
|
||||
return &MetricSet{metrics: newMetrics}
|
||||
}
|
||||
|
||||
// Metrics returns the metrics set. The returned map is read-only and must not
|
||||
// be modified.
|
||||
func (m *MetricSet) Metrics() map[string]bool {
|
||||
return m.metrics
|
||||
}
|
||||
|
||||
// Add adds the metricNames to the metrics set and returns a new copy with the
|
||||
// additional metrics.
|
||||
func (m *MetricSet) Add(metricNames ...string) *MetricSet {
|
||||
newMetrics := make(map[string]bool)
|
||||
for metric := range m.metrics {
|
||||
newMetrics[metric] = true
|
||||
}
|
||||
|
||||
for _, metric := range metricNames {
|
||||
newMetrics[metric] = true
|
||||
}
|
||||
return &MetricSet{metrics: newMetrics}
|
||||
}
|
||||
|
||||
// Join joins the metrics passed in with the metrics set, and returns a new copy
|
||||
// with the merged metrics.
|
||||
func (m *MetricSet) Join(metrics *MetricSet) *MetricSet {
|
||||
newMetrics := make(map[string]bool)
|
||||
maps.Copy(newMetrics, m.metrics)
|
||||
maps.Copy(newMetrics, metrics.metrics)
|
||||
return &MetricSet{metrics: newMetrics}
|
||||
}
|
||||
|
||||
// Remove removes the metricNames from the metrics set and returns a new copy
|
||||
// with the metrics removed.
|
||||
func (m *MetricSet) Remove(metricNames ...string) *MetricSet {
|
||||
newMetrics := make(map[string]bool)
|
||||
for metric := range m.metrics {
|
||||
newMetrics[metric] = true
|
||||
}
|
||||
|
||||
for _, metric := range metricNames {
|
||||
delete(newMetrics, metric)
|
||||
}
|
||||
return &MetricSet{metrics: newMetrics}
|
||||
}
|
||||
150
stats/opencensus/client_metrics.go
Normal file
150
stats/opencensus/client_metrics.go
Normal file
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2022 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opencensus
|
||||
|
||||
import (
|
||||
"go.opencensus.io/stats"
|
||||
"go.opencensus.io/stats/view"
|
||||
"go.opencensus.io/tag"
|
||||
)
|
||||
|
||||
var (
|
||||
keyClientMethod = tag.MustNewKey("grpc_client_method")
|
||||
keyClientStatus = tag.MustNewKey("grpc_client_status")
|
||||
)
|
||||
|
||||
// Measures, which are recorded by client stats handler: Note that due to the
|
||||
// nature of how stats handlers are called on gRPC's client side, the per rpc
|
||||
// unit is actually per attempt throughout this definition file.
|
||||
var (
|
||||
clientSentMessagesPerRPC = stats.Int64("grpc.io/client/sent_messages_per_rpc", "Number of messages sent in the RPC (always 1 for non-streaming RPCs).", stats.UnitDimensionless)
|
||||
clientSentBytesPerRPC = stats.Int64("grpc.io/client/sent_bytes_per_rpc", "Total bytes sent across all request messages per RPC.", stats.UnitBytes)
|
||||
clientSentCompressedBytesPerRPC = stats.Int64("grpc.io/client/sent_compressed_message_bytes_per_rpc", "Total compressed bytes sent across all request messages per RPC.", stats.UnitBytes)
|
||||
clientReceivedMessagesPerRPC = stats.Int64("grpc.io/client/received_messages_per_rpc", "Number of response messages received per RPC (always 1 for non-streaming RPCs).", stats.UnitDimensionless)
|
||||
clientReceivedBytesPerRPC = stats.Int64("grpc.io/client/received_bytes_per_rpc", "Total bytes received across all response messages per RPC.", stats.UnitBytes)
|
||||
clientReceivedCompressedBytesPerRPC = stats.Int64("grpc.io/client/received_compressed_message_bytes_per_rpc", "Total compressed bytes received across all response messages per RPC.", stats.UnitBytes)
|
||||
clientRoundtripLatency = stats.Float64("grpc.io/client/roundtrip_latency", "Time between first byte of request sent to last byte of response received, or terminal error.", stats.UnitMilliseconds)
|
||||
clientStartedRPCs = stats.Int64("grpc.io/client/started_rpcs", "The total number of client RPCs ever opened, including those that have not completed.", stats.UnitDimensionless)
|
||||
clientServerLatency = stats.Float64("grpc.io/client/server_latency", `Propagated from the server and should have the same value as "grpc.io/server/latency".`, stats.UnitMilliseconds)
|
||||
// Per call measure:
|
||||
clientAPILatency = stats.Float64("grpc.io/client/api_latency", "The end-to-end time the gRPC library takes to complete an RPC from the application’s perspective", stats.UnitMilliseconds)
|
||||
)
|
||||
|
||||
var (
|
||||
// ClientSentMessagesPerRPCView is the distribution of sent messages per
|
||||
// RPC, keyed on method.
|
||||
ClientSentMessagesPerRPCView = &view.View{
|
||||
Measure: clientSentMessagesPerRPC,
|
||||
Name: "grpc.io/client/sent_messages_per_rpc",
|
||||
Description: "Distribution of sent messages per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: countDistribution,
|
||||
}
|
||||
// ClientReceivedMessagesPerRPCView is the distribution of received messages
|
||||
// per RPC, keyed on method.
|
||||
ClientReceivedMessagesPerRPCView = &view.View{
|
||||
Measure: clientReceivedMessagesPerRPC,
|
||||
Name: "grpc.io/client/received_messages_per_rpc",
|
||||
Description: "Distribution of received messages per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: countDistribution,
|
||||
}
|
||||
// ClientSentBytesPerRPCView is the distribution of sent bytes per RPC,
|
||||
// keyed on method.
|
||||
ClientSentBytesPerRPCView = &view.View{
|
||||
Measure: clientSentBytesPerRPC,
|
||||
Name: "grpc.io/client/sent_bytes_per_rpc",
|
||||
Description: "Distribution of sent bytes per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ClientSentCompressedMessageBytesPerRPCView is the distribution of
|
||||
// compressed sent message bytes per RPC, keyed on method.
|
||||
ClientSentCompressedMessageBytesPerRPCView = &view.View{
|
||||
Measure: clientSentCompressedBytesPerRPC,
|
||||
Name: "grpc.io/client/sent_compressed_message_bytes_per_rpc",
|
||||
Description: "Distribution of sent compressed message bytes per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ClientReceivedBytesPerRPCView is the distribution of received bytes per
|
||||
// RPC, keyed on method.
|
||||
ClientReceivedBytesPerRPCView = &view.View{
|
||||
Measure: clientReceivedBytesPerRPC,
|
||||
Name: "grpc.io/client/received_bytes_per_rpc",
|
||||
Description: "Distribution of received bytes per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ClientReceivedCompressedMessageBytesPerRPCView is the distribution of
|
||||
// compressed received message bytes per RPC, keyed on method.
|
||||
ClientReceivedCompressedMessageBytesPerRPCView = &view.View{
|
||||
Measure: clientReceivedCompressedBytesPerRPC,
|
||||
Name: "grpc.io/client/received_compressed_message_bytes_per_rpc",
|
||||
Description: "Distribution of received compressed message bytes per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ClientStartedRPCsView is the count of opened RPCs, keyed on method.
|
||||
ClientStartedRPCsView = &view.View{
|
||||
Measure: clientStartedRPCs,
|
||||
Name: "grpc.io/client/started_rpcs",
|
||||
Description: "Number of opened client RPCs, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: view.Count(),
|
||||
}
|
||||
// ClientCompletedRPCsView is the count of completed RPCs, keyed on method
|
||||
// and status.
|
||||
ClientCompletedRPCsView = &view.View{
|
||||
Measure: clientRoundtripLatency,
|
||||
Name: "grpc.io/client/completed_rpcs",
|
||||
Description: "Number of completed RPCs by method and status.",
|
||||
TagKeys: []tag.Key{keyClientMethod, keyClientStatus},
|
||||
Aggregation: view.Count(),
|
||||
}
|
||||
// ClientRoundtripLatencyView is the distribution of round-trip latency in
|
||||
// milliseconds per RPC, keyed on method.
|
||||
ClientRoundtripLatencyView = &view.View{
|
||||
Measure: clientRoundtripLatency,
|
||||
Name: "grpc.io/client/roundtrip_latency",
|
||||
Description: "Distribution of round-trip latency, by method.",
|
||||
TagKeys: []tag.Key{keyClientMethod},
|
||||
Aggregation: millisecondsDistribution,
|
||||
}
|
||||
|
||||
// The following metric is per call:
|
||||
|
||||
// ClientAPILatencyView is the distribution of client api latency for the
|
||||
// full RPC call, keyed on method and status.
|
||||
ClientAPILatencyView = &view.View{
|
||||
Measure: clientAPILatency,
|
||||
Name: "grpc.io/client/api_latency",
|
||||
Description: "Distribution of client api latency, by method and status",
|
||||
TagKeys: []tag.Key{keyClientMethod, keyClientStatus},
|
||||
Aggregation: millisecondsDistribution,
|
||||
}
|
||||
)
|
||||
|
||||
// DefaultClientViews is the set of client views which are considered the
|
||||
// minimum required to monitor client side performance.
|
||||
var DefaultClientViews = []*view.View{
|
||||
ClientSentBytesPerRPCView,
|
||||
ClientReceivedBytesPerRPCView,
|
||||
ClientRoundtripLatencyView,
|
||||
ClientCompletedRPCsView,
|
||||
ClientStartedRPCsView,
|
||||
}
|
||||
1619
stats/opencensus/e2e_test.go
Normal file
1619
stats/opencensus/e2e_test.go
Normal file
File diff suppressed because it is too large
Load Diff
20
stats/opencensus/go.mod
Normal file
20
stats/opencensus/go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module google.golang.org/grpc/stats/opencensus
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.7.0
|
||||
go.opencensus.io v0.24.0
|
||||
google.golang.org/grpc v1.83.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260817212433-ac3dfec99bb1 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
|
||||
replace google.golang.org/grpc => ../..
|
||||
251
stats/opencensus/opencensus.go
Normal file
251
stats/opencensus/opencensus.go
Normal file
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Copyright 2022 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Package opencensus implements opencensus instrumentation code for gRPC-Go
|
||||
// clients and servers.
|
||||
package opencensus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ocstats "go.opencensus.io/stats"
|
||||
"go.opencensus.io/tag"
|
||||
"go.opencensus.io/trace"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/internal"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var (
|
||||
joinDialOptions = internal.JoinDialOptions.(func(...grpc.DialOption) grpc.DialOption)
|
||||
)
|
||||
|
||||
// TraceOptions are the tracing options for opencensus instrumentation.
|
||||
type TraceOptions struct {
|
||||
// TS is the Sampler used for tracing.
|
||||
TS trace.Sampler
|
||||
// DisableTrace determines whether traces are disabled for an OpenCensus
|
||||
// Dial or Server option. will overwrite any global option setting.
|
||||
DisableTrace bool
|
||||
}
|
||||
|
||||
// DialOption returns a dial option which enables OpenCensus instrumentation
|
||||
// code for a grpc.ClientConn.
|
||||
//
|
||||
// Client applications interested in instrumenting their grpc.ClientConn should
|
||||
// pass the dial option returned from this function as the first dial option to
|
||||
// grpc.Dial().
|
||||
//
|
||||
// Using this option will always lead to instrumentation, however in order to
|
||||
// use the data an exporter must be registered with the OpenCensus trace package
|
||||
// for traces and the OpenCensus view package for metrics. Client side has
|
||||
// retries, so a Unary and Streaming Interceptor are registered to handle per
|
||||
// RPC traces/metrics, and a Stats Handler is registered to handle per RPC
|
||||
// attempt trace/metrics. These three components registered work together in
|
||||
// conjunction, and do not work standalone. It is not supported to use this
|
||||
// alongside another stats handler dial option.
|
||||
func DialOption(to TraceOptions) grpc.DialOption {
|
||||
csh := &clientStatsHandler{to: to}
|
||||
return joinDialOptions(grpc.WithChainUnaryInterceptor(csh.unaryInterceptor), grpc.WithChainStreamInterceptor(csh.streamInterceptor), grpc.WithStatsHandler(csh))
|
||||
}
|
||||
|
||||
// ServerOption returns a server option which enables OpenCensus instrumentation
|
||||
// code for a grpc.Server.
|
||||
//
|
||||
// Server applications interested in instrumenting their grpc.Server should
|
||||
// pass the server option returned from this function as the first argument to
|
||||
// grpc.NewServer().
|
||||
//
|
||||
// Using this option will always lead to instrumentation, however in order to
|
||||
// use the data an exporter must be registered with the OpenCensus trace package
|
||||
// for traces and the OpenCensus view package for metrics. Server side does not
|
||||
// have retries, so a registered Stats Handler is the only option that is
|
||||
// returned. It is not supported to use this alongside another stats handler
|
||||
// server option.
|
||||
func ServerOption(to TraceOptions) grpc.ServerOption {
|
||||
return grpc.StatsHandler(&serverStatsHandler{to: to})
|
||||
}
|
||||
|
||||
// createCallSpan creates a call span if tracing is enabled, which will be put
|
||||
// in the context provided if created.
|
||||
func (csh *clientStatsHandler) createCallSpan(ctx context.Context, method string) (context.Context, *trace.Span) {
|
||||
var span *trace.Span
|
||||
if !csh.to.DisableTrace {
|
||||
mn := strings.ReplaceAll(removeLeadingSlash(method), "/", ".")
|
||||
ctx, span = trace.StartSpan(ctx, mn, trace.WithSampler(csh.to.TS), trace.WithSpanKind(trace.SpanKindClient))
|
||||
}
|
||||
return ctx, span
|
||||
}
|
||||
|
||||
// perCallTracesAndMetrics records per call spans and metrics.
|
||||
func perCallTracesAndMetrics(err error, span *trace.Span, startTime time.Time, method string) {
|
||||
s := status.Convert(err)
|
||||
if span != nil {
|
||||
span.SetStatus(trace.Status{Code: int32(s.Code()), Message: s.Message()})
|
||||
span.End()
|
||||
}
|
||||
callLatency := float64(time.Since(startTime)) / float64(time.Millisecond)
|
||||
ocstats.RecordWithOptions(context.Background(),
|
||||
ocstats.WithTags(
|
||||
tag.Upsert(keyClientMethod, removeLeadingSlash(method)),
|
||||
tag.Upsert(keyClientStatus, canonicalString(s.Code())),
|
||||
),
|
||||
ocstats.WithMeasurements(
|
||||
clientAPILatency.M(callLatency),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// unaryInterceptor handles per RPC context management. It also handles per RPC
|
||||
// tracing and stats by creating a top level call span and recording the latency
|
||||
// for the full RPC call.
|
||||
func (csh *clientStatsHandler) unaryInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
startTime := time.Now()
|
||||
ctx, span := csh.createCallSpan(ctx, method)
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
perCallTracesAndMetrics(err, span, startTime, method)
|
||||
return err
|
||||
}
|
||||
|
||||
// streamInterceptor handles per RPC context management. It also handles per RPC
|
||||
// tracing and stats by creating a top level call span and recording the latency
|
||||
// for the full RPC call.
|
||||
func (csh *clientStatsHandler) streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
startTime := time.Now()
|
||||
ctx, span := csh.createCallSpan(ctx, method)
|
||||
callback := func(err error) {
|
||||
perCallTracesAndMetrics(err, span, startTime, method)
|
||||
}
|
||||
opts = append([]grpc.CallOption{grpc.OnFinish(callback)}, opts...)
|
||||
s, err := streamer(ctx, desc, cc, method, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type rpcInfo struct {
|
||||
mi *metricsInfo
|
||||
ti *traceInfo
|
||||
}
|
||||
|
||||
type rpcInfoKey struct{}
|
||||
|
||||
func setRPCInfo(ctx context.Context, ri *rpcInfo) context.Context {
|
||||
return context.WithValue(ctx, rpcInfoKey{}, ri)
|
||||
}
|
||||
|
||||
// getRPCInfo returns the rpcInfo stored in the context, or nil
|
||||
// if there isn't one.
|
||||
func getRPCInfo(ctx context.Context) *rpcInfo {
|
||||
ri, _ := ctx.Value(rpcInfoKey{}).(*rpcInfo)
|
||||
return ri
|
||||
}
|
||||
|
||||
// SpanContextFromContext returns the Span Context about the Span in the
|
||||
// context. Returns false if no Span in the context.
|
||||
func SpanContextFromContext(ctx context.Context) (trace.SpanContext, bool) {
|
||||
ri, ok := ctx.Value(rpcInfoKey{}).(*rpcInfo)
|
||||
if !ok {
|
||||
return trace.SpanContext{}, false
|
||||
}
|
||||
if ri.ti == nil || ri.ti.span == nil {
|
||||
return trace.SpanContext{}, false
|
||||
}
|
||||
sc := ri.ti.span.SpanContext()
|
||||
return sc, true
|
||||
}
|
||||
|
||||
type clientStatsHandler struct {
|
||||
to TraceOptions
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler.
|
||||
func (csh *clientStatsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler.
|
||||
func (csh *clientStatsHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
|
||||
// TagRPC implements per RPC attempt context management.
|
||||
func (csh *clientStatsHandler) TagRPC(ctx context.Context, rti *stats.RPCTagInfo) context.Context {
|
||||
ctx, mi := csh.statsTagRPC(ctx, rti)
|
||||
var ti *traceInfo
|
||||
if !csh.to.DisableTrace {
|
||||
ctx, ti = csh.traceTagRPC(ctx, rti)
|
||||
}
|
||||
ri := &rpcInfo{
|
||||
mi: mi,
|
||||
ti: ti,
|
||||
}
|
||||
return setRPCInfo(ctx, ri)
|
||||
}
|
||||
|
||||
func (csh *clientStatsHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := getRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
// Shouldn't happen because TagRPC populates this information.
|
||||
return
|
||||
}
|
||||
recordRPCData(ctx, rs, ri.mi)
|
||||
if !csh.to.DisableTrace {
|
||||
populateSpan(ctx, rs, ri.ti)
|
||||
}
|
||||
}
|
||||
|
||||
type serverStatsHandler struct {
|
||||
to TraceOptions
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler.
|
||||
func (ssh *serverStatsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler.
|
||||
func (ssh *serverStatsHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
|
||||
// TagRPC implements per RPC context management.
|
||||
func (ssh *serverStatsHandler) TagRPC(ctx context.Context, rti *stats.RPCTagInfo) context.Context {
|
||||
ctx, mi := ssh.statsTagRPC(ctx, rti)
|
||||
var ti *traceInfo
|
||||
if !ssh.to.DisableTrace {
|
||||
ctx, ti = ssh.traceTagRPC(ctx, rti)
|
||||
}
|
||||
ri := &rpcInfo{
|
||||
mi: mi,
|
||||
ti: ti,
|
||||
}
|
||||
return setRPCInfo(ctx, ri)
|
||||
}
|
||||
|
||||
// HandleRPC implements per RPC tracing and stats implementation.
|
||||
func (ssh *serverStatsHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := getRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
// Shouldn't happen because TagRPC populates this information.
|
||||
return
|
||||
}
|
||||
recordRPCData(ctx, rs, ri.mi)
|
||||
if !ssh.to.DisableTrace {
|
||||
populateSpan(ctx, rs, ri.ti)
|
||||
}
|
||||
}
|
||||
135
stats/opencensus/server_metrics.go
Normal file
135
stats/opencensus/server_metrics.go
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright 2022 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opencensus
|
||||
|
||||
import (
|
||||
"go.opencensus.io/stats"
|
||||
"go.opencensus.io/stats/view"
|
||||
"go.opencensus.io/tag"
|
||||
)
|
||||
|
||||
var (
|
||||
keyServerMethod = tag.MustNewKey("grpc_server_method")
|
||||
keyServerStatus = tag.MustNewKey("grpc_server_status")
|
||||
)
|
||||
|
||||
// Measures, which are recorded by server stats handler: Note that on gRPC's
|
||||
// server side, the per rpc unit is truly per rpc, as there is no concept of a
|
||||
// rpc attempt server side.
|
||||
var (
|
||||
serverReceivedMessagesPerRPC = stats.Int64("grpc.io/server/received_messages_per_rpc", "Number of messages received in each RPC. Has value 1 for non-streaming RPCs.", stats.UnitDimensionless) // the collection/measurement point of this measure handles the /rpc aspect of it
|
||||
serverReceivedBytesPerRPC = stats.Int64("grpc.io/server/received_bytes_per_rpc", "Total bytes received across all messages per RPC.", stats.UnitBytes)
|
||||
serverReceivedCompressedBytesPerRPC = stats.Int64("grpc.io/server/received_compressed_bytes_per_rpc", "Total compressed bytes received across all messages per RPC.", stats.UnitBytes)
|
||||
serverSentMessagesPerRPC = stats.Int64("grpc.io/server/sent_messages_per_rpc", "Number of messages sent in each RPC. Has value 1 for non-streaming RPCs.", stats.UnitDimensionless)
|
||||
serverSentBytesPerRPC = stats.Int64("grpc.io/server/sent_bytes_per_rpc", "Total bytes sent in across all response messages per RPC.", stats.UnitBytes)
|
||||
serverSentCompressedBytesPerRPC = stats.Int64("grpc.io/server/sent_compressed_bytes_per_rpc", "Total compressed bytes sent in across all response messages per RPC.", stats.UnitBytes)
|
||||
serverStartedRPCs = stats.Int64("grpc.io/server/started_rpcs", "The total number of server RPCs ever opened, including those that have not completed.", stats.UnitDimensionless)
|
||||
serverLatency = stats.Float64("grpc.io/server/server_latency", "Time between first byte of request received to last byte of response sent, or terminal error.", stats.UnitMilliseconds)
|
||||
)
|
||||
|
||||
var (
|
||||
// ServerSentMessagesPerRPCView is the distribution of sent messages per
|
||||
// RPC, keyed on method.
|
||||
ServerSentMessagesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/sent_messages_per_rpc",
|
||||
Description: "Distribution of sent messages per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Measure: serverSentMessagesPerRPC,
|
||||
Aggregation: countDistribution,
|
||||
}
|
||||
// ServerReceivedMessagesPerRPCView is the distribution of received messages
|
||||
// per RPC, keyed on method.
|
||||
ServerReceivedMessagesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/received_messages_per_rpc",
|
||||
Description: "Distribution of received messages per RPC, by method.",
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Measure: serverReceivedMessagesPerRPC,
|
||||
Aggregation: countDistribution,
|
||||
}
|
||||
// ServerSentBytesPerRPCView is the distribution of received bytes per RPC,
|
||||
// keyed on method.
|
||||
ServerSentBytesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/sent_bytes_per_rpc",
|
||||
Description: "Distribution of sent bytes per RPC, by method.",
|
||||
Measure: serverSentBytesPerRPC,
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ServerSentCompressedMessageBytesPerRPCView is the distribution of
|
||||
// received compressed message bytes per RPC, keyed on method.
|
||||
ServerSentCompressedMessageBytesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/sent_compressed_message_bytes_per_rpc",
|
||||
Description: "Distribution of sent compressed message bytes per RPC, by method.",
|
||||
Measure: serverSentCompressedBytesPerRPC,
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ServerReceivedBytesPerRPCView is the distribution of sent bytes per RPC,
|
||||
// keyed on method.
|
||||
ServerReceivedBytesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/received_bytes_per_rpc",
|
||||
Description: "Distribution of received bytes per RPC, by method.",
|
||||
Measure: serverReceivedBytesPerRPC,
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ServerReceivedCompressedMessageBytesPerRPCView is the distribution of
|
||||
// sent compressed message bytes per RPC, keyed on method.
|
||||
ServerReceivedCompressedMessageBytesPerRPCView = &view.View{
|
||||
Name: "grpc.io/server/received_compressed_message_bytes_per_rpc",
|
||||
Description: "Distribution of received compressed message bytes per RPC, by method.",
|
||||
Measure: serverReceivedCompressedBytesPerRPC,
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Aggregation: bytesDistribution,
|
||||
}
|
||||
// ServerStartedRPCsView is the count of opened RPCs, keyed on method.
|
||||
ServerStartedRPCsView = &view.View{
|
||||
Measure: serverStartedRPCs,
|
||||
Name: "grpc.io/server/started_rpcs",
|
||||
Description: "Number of opened server RPCs, by method.",
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Aggregation: view.Count(),
|
||||
}
|
||||
// ServerCompletedRPCsView is the count of completed RPCs, keyed on
|
||||
// method and status.
|
||||
ServerCompletedRPCsView = &view.View{
|
||||
Name: "grpc.io/server/completed_rpcs",
|
||||
Description: "Number of completed RPCs by method and status.",
|
||||
TagKeys: []tag.Key{keyServerMethod, keyServerStatus},
|
||||
Measure: serverLatency,
|
||||
Aggregation: view.Count(),
|
||||
}
|
||||
// ServerLatencyView is the distribution of server latency in milliseconds
|
||||
// per RPC, keyed on method.
|
||||
ServerLatencyView = &view.View{
|
||||
Name: "grpc.io/server/server_latency",
|
||||
Description: "Distribution of server latency in milliseconds, by method.",
|
||||
TagKeys: []tag.Key{keyServerMethod},
|
||||
Measure: serverLatency,
|
||||
Aggregation: millisecondsDistribution,
|
||||
}
|
||||
)
|
||||
|
||||
// DefaultServerViews is the set of server views which are considered the
|
||||
// minimum required to monitor server side performance.
|
||||
var DefaultServerViews = []*view.View{
|
||||
ServerReceivedBytesPerRPCView,
|
||||
ServerSentBytesPerRPCView,
|
||||
ServerLatencyView,
|
||||
ServerCompletedRPCsView,
|
||||
ServerStartedRPCsView,
|
||||
}
|
||||
226
stats/opencensus/stats.go
Normal file
226
stats/opencensus/stats.go
Normal file
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
* Copyright 2022 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opencensus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
ocstats "go.opencensus.io/stats"
|
||||
"go.opencensus.io/stats/view"
|
||||
"go.opencensus.io/tag"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"google.golang.org/grpc/internal"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var logger = grpclog.Component("opencensus-instrumentation")
|
||||
|
||||
var canonicalString = internal.CanonicalString.(func(codes.Code) string)
|
||||
|
||||
var (
|
||||
// bounds separate variable for testing purposes.
|
||||
bytesDistributionBounds = []float64{1024, 2048, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
|
||||
bytesDistribution = view.Distribution(bytesDistributionBounds...)
|
||||
millisecondsDistribution = view.Distribution(0.01, 0.05, 0.1, 0.3, 0.6, 0.8, 1, 2, 3, 4, 5, 6, 8, 10, 13, 16, 20, 25, 30, 40, 50, 65, 80, 100, 130, 160, 200, 250, 300, 400, 500, 650, 800, 1000, 2000, 5000, 10000, 20000, 50000, 100000)
|
||||
countDistributionBounds = []float64{1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536}
|
||||
countDistribution = view.Distribution(countDistributionBounds...)
|
||||
)
|
||||
|
||||
func removeLeadingSlash(mn string) string {
|
||||
return strings.TrimLeft(mn, "/")
|
||||
}
|
||||
|
||||
// metricsInfo is data used for recording metrics about the rpc attempt client
|
||||
// side, and the overall rpc server side.
|
||||
type metricsInfo struct {
|
||||
// access these counts atomically for hedging in the future
|
||||
// number of messages sent from side (client || server)
|
||||
sentMsgs int64
|
||||
// number of bytes sent (within each message) from side (client || server)
|
||||
sentBytes int64
|
||||
// number of bytes after compression (within each message) from side (client || server)
|
||||
sentCompressedBytes int64
|
||||
// number of messages received on side (client || server)
|
||||
recvMsgs int64
|
||||
// number of bytes received (within each message) received on side (client
|
||||
// || server)
|
||||
recvBytes int64
|
||||
// number of compressed bytes received (within each message) received on
|
||||
// side (client || server)
|
||||
recvCompressedBytes int64
|
||||
|
||||
startTime time.Time
|
||||
method string
|
||||
}
|
||||
|
||||
// statsTagRPC creates a recording object to derive measurements from in the
|
||||
// context, scoping the recordings to per RPC Attempt client side (scope of the
|
||||
// context). It also populates the gRPC Metadata within the context with any
|
||||
// opencensus specific tags set by the application in the context, binary
|
||||
// encoded to send across the wire.
|
||||
func (csh *clientStatsHandler) statsTagRPC(ctx context.Context, info *stats.RPCTagInfo) (context.Context, *metricsInfo) {
|
||||
mi := &metricsInfo{
|
||||
startTime: time.Now(),
|
||||
method: info.FullMethodName,
|
||||
}
|
||||
|
||||
// Populate gRPC Metadata with OpenCensus tag map if set by application.
|
||||
if tm := tag.FromContext(ctx); tm != nil {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, "grpc-tags-bin", string(tag.Encode(tm)))
|
||||
}
|
||||
return ctx, mi
|
||||
}
|
||||
|
||||
// statsTagRPC creates a recording object to derive measurements from in the
|
||||
// context, scoping the recordings to per RPC server side (scope of the
|
||||
// context). It also deserializes the opencensus tags set in the context's gRPC
|
||||
// Metadata, and adds a server method tag to the opencensus tags. If multiple
|
||||
// tags exist, it adds the last one.
|
||||
func (ssh *serverStatsHandler) statsTagRPC(ctx context.Context, info *stats.RPCTagInfo) (context.Context, *metricsInfo) {
|
||||
mi := &metricsInfo{
|
||||
startTime: time.Now(),
|
||||
method: info.FullMethodName,
|
||||
}
|
||||
|
||||
if tgValues := metadata.ValueFromIncomingContext(ctx, "grpc-tags-bin"); len(tgValues) > 0 {
|
||||
tagsBin := []byte(tgValues[len(tgValues)-1])
|
||||
if tags, err := tag.Decode(tagsBin); err == nil {
|
||||
ctx = tag.NewContext(ctx, tags)
|
||||
}
|
||||
}
|
||||
|
||||
// We can ignore the error here because in the error case, the context
|
||||
// passed in is returned. If the call errors, the server side application
|
||||
// layer won't get this key server method information in the tag map, but
|
||||
// this instrumentation code will function as normal.
|
||||
ctx, _ = tag.New(ctx, tag.Upsert(keyServerMethod, removeLeadingSlash(info.FullMethodName)))
|
||||
return ctx, mi
|
||||
}
|
||||
|
||||
func recordRPCData(ctx context.Context, s stats.RPCStats, mi *metricsInfo) {
|
||||
if mi == nil {
|
||||
// Shouldn't happen, as gRPC calls TagRPC which populates the metricsInfo in
|
||||
// context.
|
||||
logger.Error("ctx passed into stats handler metrics event handling has no metrics data present")
|
||||
return
|
||||
}
|
||||
switch st := s.(type) {
|
||||
case *stats.InHeader, *stats.OutHeader, *stats.InTrailer, *stats.OutTrailer, *stats.PickerUpdated:
|
||||
// Headers, Trailers, and picker updates are not relevant to the measures,
|
||||
// as the measures concern number of messages and bytes for messages. This
|
||||
// aligns with flow control.
|
||||
case *stats.Begin:
|
||||
recordDataBegin(ctx, mi, st)
|
||||
case *stats.OutPayload:
|
||||
recordDataOutPayload(mi, st)
|
||||
case *stats.InPayload:
|
||||
recordDataInPayload(mi, st)
|
||||
case *stats.End:
|
||||
recordDataEnd(ctx, mi, st)
|
||||
default:
|
||||
// Shouldn't happen. gRPC calls into stats handler, and will never not
|
||||
// be one of the types above.
|
||||
logger.Errorf("Received unexpected stats type (%T) with data: %v", s, s)
|
||||
}
|
||||
}
|
||||
|
||||
// recordDataBegin takes a measurement related to the RPC beginning,
|
||||
// client/server started RPCs dependent on the caller.
|
||||
func recordDataBegin(ctx context.Context, mi *metricsInfo, b *stats.Begin) {
|
||||
if b.Client {
|
||||
ocstats.RecordWithOptions(ctx,
|
||||
ocstats.WithTags(tag.Upsert(keyClientMethod, removeLeadingSlash(mi.method))),
|
||||
ocstats.WithMeasurements(clientStartedRPCs.M(1)))
|
||||
return
|
||||
}
|
||||
ocstats.RecordWithOptions(ctx,
|
||||
ocstats.WithTags(tag.Upsert(keyServerMethod, removeLeadingSlash(mi.method))),
|
||||
ocstats.WithMeasurements(serverStartedRPCs.M(1)))
|
||||
}
|
||||
|
||||
// recordDataOutPayload records the length in bytes of outgoing messages and
|
||||
// increases total count of sent messages both stored in the RPCs (attempt on
|
||||
// client side) context for use in taking measurements at RPC end.
|
||||
func recordDataOutPayload(mi *metricsInfo, op *stats.OutPayload) {
|
||||
atomic.AddInt64(&mi.sentMsgs, 1)
|
||||
atomic.AddInt64(&mi.sentBytes, int64(op.Length))
|
||||
atomic.AddInt64(&mi.sentCompressedBytes, int64(op.CompressedLength))
|
||||
}
|
||||
|
||||
// recordDataInPayload records the length in bytes of incoming messages and
|
||||
// increases total count of sent messages both stored in the RPCs (attempt on
|
||||
// client side) context for use in taking measurements at RPC end.
|
||||
func recordDataInPayload(mi *metricsInfo, ip *stats.InPayload) {
|
||||
atomic.AddInt64(&mi.recvMsgs, 1)
|
||||
atomic.AddInt64(&mi.recvBytes, int64(ip.Length))
|
||||
atomic.AddInt64(&mi.recvCompressedBytes, int64(ip.CompressedLength))
|
||||
}
|
||||
|
||||
// recordDataEnd takes per RPC measurements derived from information derived
|
||||
// from the lifetime of the RPC (RPC attempt client side).
|
||||
func recordDataEnd(ctx context.Context, mi *metricsInfo, e *stats.End) {
|
||||
// latency bounds for distribution data (speced millisecond bounds) have
|
||||
// fractions, thus need a float.
|
||||
latency := float64(time.Since(mi.startTime)) / float64(time.Millisecond)
|
||||
var st string
|
||||
if e.Error != nil {
|
||||
s, _ := status.FromError(e.Error)
|
||||
st = canonicalString(s.Code())
|
||||
} else {
|
||||
st = "OK"
|
||||
}
|
||||
|
||||
// TODO: Attach trace data through attachments?!?!
|
||||
|
||||
if e.Client {
|
||||
ocstats.RecordWithOptions(ctx,
|
||||
ocstats.WithTags(
|
||||
tag.Upsert(keyClientMethod, removeLeadingSlash(mi.method)),
|
||||
tag.Upsert(keyClientStatus, st)),
|
||||
ocstats.WithMeasurements(
|
||||
clientSentBytesPerRPC.M(atomic.LoadInt64(&mi.sentBytes)),
|
||||
clientSentCompressedBytesPerRPC.M(atomic.LoadInt64(&mi.sentCompressedBytes)),
|
||||
clientSentMessagesPerRPC.M(atomic.LoadInt64(&mi.sentMsgs)),
|
||||
clientReceivedMessagesPerRPC.M(atomic.LoadInt64(&mi.recvMsgs)),
|
||||
clientReceivedBytesPerRPC.M(atomic.LoadInt64(&mi.recvBytes)),
|
||||
clientReceivedCompressedBytesPerRPC.M(atomic.LoadInt64(&mi.recvCompressedBytes)),
|
||||
clientRoundtripLatency.M(latency),
|
||||
clientServerLatency.M(latency),
|
||||
))
|
||||
return
|
||||
}
|
||||
ocstats.RecordWithOptions(ctx,
|
||||
ocstats.WithTags(
|
||||
tag.Upsert(keyServerMethod, removeLeadingSlash(mi.method)),
|
||||
tag.Upsert(keyServerStatus, st),
|
||||
),
|
||||
ocstats.WithMeasurements(
|
||||
serverSentBytesPerRPC.M(atomic.LoadInt64(&mi.sentBytes)),
|
||||
serverSentCompressedBytesPerRPC.M(atomic.LoadInt64(&mi.sentCompressedBytes)),
|
||||
serverSentMessagesPerRPC.M(atomic.LoadInt64(&mi.sentMsgs)),
|
||||
serverReceivedMessagesPerRPC.M(atomic.LoadInt64(&mi.recvMsgs)),
|
||||
serverReceivedBytesPerRPC.M(atomic.LoadInt64(&mi.recvBytes)),
|
||||
serverReceivedCompressedBytesPerRPC.M(atomic.LoadInt64(&mi.recvCompressedBytes)),
|
||||
serverLatency.M(latency)))
|
||||
}
|
||||
130
stats/opencensus/trace.go
Normal file
130
stats/opencensus/trace.go
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Copyright 2022 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opencensus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"go.opencensus.io/trace"
|
||||
"go.opencensus.io/trace/propagation"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// traceInfo is data used for recording traces.
|
||||
type traceInfo struct {
|
||||
span *trace.Span
|
||||
countSentMsg uint32
|
||||
countRecvMsg uint32
|
||||
}
|
||||
|
||||
// traceTagRPC populates context with a new span, and serializes information
|
||||
// about this span into gRPC Metadata.
|
||||
func (csh *clientStatsHandler) traceTagRPC(ctx context.Context, rti *stats.RPCTagInfo) (context.Context, *traceInfo) {
|
||||
// TODO: get consensus on whether this method name of "s.m" is correct.
|
||||
mn := "Attempt." + strings.ReplaceAll(removeLeadingSlash(rti.FullMethodName), "/", ".")
|
||||
// Returned context is ignored because will populate context with data that
|
||||
// wraps the span instead. Don't set span kind client on this attempt span
|
||||
// to prevent backend from prepending span name with "Sent.".
|
||||
_, span := trace.StartSpan(ctx, mn, trace.WithSampler(csh.to.TS))
|
||||
|
||||
tcBin := propagation.Binary(span.SpanContext())
|
||||
return metadata.AppendToOutgoingContext(ctx, "grpc-trace-bin", string(tcBin)), &traceInfo{
|
||||
span: span,
|
||||
countSentMsg: 0, // msg events scoped to scope of context, per attempt client side
|
||||
countRecvMsg: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// traceTagRPC populates context with new span data, with a parent based on the
|
||||
// spanContext deserialized from context passed in (wire data in gRPC metadata)
|
||||
// if present. If multiple spanContexts exist, it takes the last one.
|
||||
func (ssh *serverStatsHandler) traceTagRPC(ctx context.Context, rti *stats.RPCTagInfo) (context.Context, *traceInfo) {
|
||||
mn := strings.ReplaceAll(removeLeadingSlash(rti.FullMethodName), "/", ".")
|
||||
|
||||
var tcBin []byte
|
||||
if tcValues := metadata.ValueFromIncomingContext(ctx, "grpc-trace-bin"); len(tcValues) > 0 {
|
||||
tcBin = []byte(tcValues[len(tcValues)-1])
|
||||
}
|
||||
|
||||
var span *trace.Span
|
||||
if sc, ok := propagation.FromBinary(tcBin); ok {
|
||||
// Returned context is ignored because will populate context with data
|
||||
// that wraps the span instead.
|
||||
_, span = trace.StartSpanWithRemoteParent(ctx, mn, sc, trace.WithSpanKind(trace.SpanKindServer), trace.WithSampler(ssh.to.TS))
|
||||
span.AddLink(trace.Link{TraceID: sc.TraceID, SpanID: sc.SpanID, Type: trace.LinkTypeChild})
|
||||
} else {
|
||||
// Returned context is ignored because will populate context with data
|
||||
// that wraps the span instead.
|
||||
_, span = trace.StartSpan(ctx, mn, trace.WithSpanKind(trace.SpanKindServer), trace.WithSampler(ssh.to.TS))
|
||||
}
|
||||
|
||||
return ctx, &traceInfo{
|
||||
span: span,
|
||||
countSentMsg: 0,
|
||||
countRecvMsg: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// populateSpan populates span information based on stats passed in (invariants
|
||||
// of the RPC lifecycle), and also ends span which triggers the span to be
|
||||
// exported.
|
||||
func populateSpan(_ context.Context, rs stats.RPCStats, ti *traceInfo) {
|
||||
if ti == nil || ti.span == nil {
|
||||
// Shouldn't happen, tagRPC call comes before this function gets called
|
||||
// which populates this information.
|
||||
logger.Error("ctx passed into stats handler tracing event handling has no span present")
|
||||
return
|
||||
}
|
||||
span := ti.span
|
||||
|
||||
switch rs := rs.(type) {
|
||||
case *stats.Begin:
|
||||
// Note: Go always added these attributes even though they are not
|
||||
// defined by the OpenCensus gRPC spec. Thus, they are unimportant for
|
||||
// correctness.
|
||||
span.AddAttributes(
|
||||
trace.BoolAttribute("Client", rs.Client),
|
||||
trace.BoolAttribute("FailFast", rs.FailFast),
|
||||
)
|
||||
case *stats.PickerUpdated:
|
||||
span.Annotate(nil, "Delayed LB pick complete")
|
||||
case *stats.InPayload:
|
||||
// message id - "must be calculated as two different counters starting
|
||||
// from one for sent messages and one for received messages."
|
||||
mi := atomic.AddUint32(&ti.countRecvMsg, 1)
|
||||
span.AddMessageReceiveEvent(int64(mi), int64(rs.Length), int64(rs.CompressedLength))
|
||||
case *stats.OutPayload:
|
||||
mi := atomic.AddUint32(&ti.countSentMsg, 1)
|
||||
span.AddMessageSendEvent(int64(mi), int64(rs.Length), int64(rs.CompressedLength))
|
||||
case *stats.End:
|
||||
if rs.Error != nil {
|
||||
// "The mapping between gRPC canonical codes and OpenCensus codes
|
||||
// can be found here", which implies 1:1 mapping to gRPC statuses
|
||||
// (OpenCensus statuses are based off gRPC statuses and a subset).
|
||||
s := status.Convert(rs.Error)
|
||||
span.SetStatus(trace.Status{Code: int32(s.Code()), Message: s.Message()})
|
||||
} else {
|
||||
span.SetStatus(trace.Status{Code: trace.StatusCodeOK}) // could get rid of this else conditional and just leave as 0 value, but this makes it explicit
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
287
stats/opentelemetry/client_metrics.go
Normal file
287
stats/opentelemetry/client_metrics.go
Normal file
@@ -0,0 +1,287 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
otelattribute "go.opentelemetry.io/otel/attribute"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
"google.golang.org/grpc"
|
||||
estats "google.golang.org/grpc/experimental/stats"
|
||||
istats "google.golang.org/grpc/internal/stats"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type clientMetricsHandler struct {
|
||||
estats.MetricsRecorder
|
||||
options Options
|
||||
clientMetrics clientMetrics
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) initializeMetrics() {
|
||||
// Will set no metrics to record, logically making this stats handler a
|
||||
// no-op.
|
||||
if h.options.MetricsOptions.MeterProvider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
meter := h.options.MetricsOptions.MeterProvider.Meter("grpc-go", otelmetric.WithInstrumentationVersion(grpc.Version))
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
metrics := h.options.MetricsOptions.Metrics
|
||||
if metrics == nil {
|
||||
metrics = DefaultMetrics()
|
||||
}
|
||||
|
||||
h.clientMetrics.attemptStarted = createInt64Counter(metrics.Metrics(), "grpc.client.attempt.started", meter, otelmetric.WithUnit("{attempt}"), otelmetric.WithDescription("Number of client call attempts started."))
|
||||
h.clientMetrics.attemptDuration = createFloat64Histogram(metrics.Metrics(), "grpc.client.attempt.duration", meter, otelmetric.WithUnit("s"), otelmetric.WithDescription("End-to-end time taken to complete a client call attempt."), otelmetric.WithExplicitBucketBoundaries(DefaultLatencyBounds...))
|
||||
h.clientMetrics.attemptSentTotalCompressedMessageSize = createInt64Histogram(metrics.Metrics(), "grpc.client.attempt.sent_total_compressed_message_size", meter, otelmetric.WithUnit("By"), otelmetric.WithDescription("Compressed message bytes sent per client call attempt."), otelmetric.WithExplicitBucketBoundaries(DefaultSizeBounds...))
|
||||
h.clientMetrics.attemptRcvdTotalCompressedMessageSize = createInt64Histogram(metrics.Metrics(), "grpc.client.attempt.rcvd_total_compressed_message_size", meter, otelmetric.WithUnit("By"), otelmetric.WithDescription("Compressed message bytes received per call attempt."), otelmetric.WithExplicitBucketBoundaries(DefaultSizeBounds...))
|
||||
h.clientMetrics.callDuration = createFloat64Histogram(metrics.Metrics(), "grpc.client.call.duration", meter, otelmetric.WithUnit("s"), otelmetric.WithDescription("Time taken by gRPC to complete an RPC from application's perspective."), otelmetric.WithExplicitBucketBoundaries(DefaultLatencyBounds...))
|
||||
|
||||
rm := ®istryMetrics{
|
||||
optionalLabels: h.options.MetricsOptions.OptionalLabels,
|
||||
}
|
||||
h.MetricsRecorder = rm
|
||||
rm.registerMetrics(metrics, meter)
|
||||
}
|
||||
|
||||
// getOrCreateCallInfo returns the existing callInfo from context if present,
|
||||
// or creates and attaches a new one.
|
||||
func getOrCreateCallInfo(ctx context.Context, cc *grpc.ClientConn, method string, opts ...grpc.CallOption) (context.Context, *callInfo) {
|
||||
ci := getCallInfo(ctx)
|
||||
if ci == nil {
|
||||
ci = &callInfo{
|
||||
target: cc.CanonicalTarget(),
|
||||
method: determineMethod(method, opts...),
|
||||
}
|
||||
ctx = context.WithValue(ctx, callInfoKey{}, ci)
|
||||
}
|
||||
return ctx, ci
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) unaryInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
ctx, ci := getOrCreateCallInfo(ctx, cc, method, opts...)
|
||||
|
||||
if h.options.MetricsOptions.pluginOption != nil {
|
||||
md := h.options.MetricsOptions.pluginOption.GetMetadata()
|
||||
for k, vs := range md {
|
||||
for _, v := range vs {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
h.perCallMetrics(ctx, err, startTime, ci)
|
||||
return err
|
||||
}
|
||||
|
||||
// determineMethod determines the method to record attributes with. This will be
|
||||
// "other" if StaticMethod isn't specified or if method filter is set and
|
||||
// specifies, the method name as is otherwise.
|
||||
func determineMethod(method string, opts ...grpc.CallOption) string {
|
||||
for _, opt := range opts {
|
||||
if _, ok := opt.(grpc.StaticMethodCallOption); ok {
|
||||
return removeLeadingSlash(method)
|
||||
}
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
ctx, ci := getOrCreateCallInfo(ctx, cc, method, opts...)
|
||||
|
||||
if h.options.MetricsOptions.pluginOption != nil {
|
||||
md := h.options.MetricsOptions.pluginOption.GetMetadata()
|
||||
for k, vs := range md {
|
||||
for _, v := range vs {
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
callback := func(err error) {
|
||||
h.perCallMetrics(ctx, err, startTime, ci)
|
||||
}
|
||||
opts = append([]grpc.CallOption{grpc.OnFinish(callback)}, opts...)
|
||||
return streamer(ctx, desc, cc, method, opts...)
|
||||
}
|
||||
|
||||
// perCallMetrics records per call metrics for both unary and stream calls.
|
||||
func (h *clientMetricsHandler) perCallMetrics(ctx context.Context, err error, startTime time.Time, ci *callInfo) {
|
||||
callLatency := float64(time.Since(startTime)) / float64(time.Second)
|
||||
attributes := []otelattribute.KeyValue{
|
||||
otelattribute.String("grpc.method", ci.method),
|
||||
otelattribute.String("grpc.target", ci.target),
|
||||
otelattribute.String("grpc.status", canonicalString(status.Code(err))),
|
||||
}
|
||||
for _, o := range h.options.MetricsOptions.OptionalLabels {
|
||||
if o == "grpc.client.call.custom" {
|
||||
label := estats.CustomLabelFromContext(ctx)
|
||||
attributes = append(attributes, otelattribute.String(o, label))
|
||||
}
|
||||
}
|
||||
attrs := otelmetric.WithAttributeSet(otelattribute.NewSet(attributes...))
|
||||
h.clientMetrics.callDuration.Record(ctx, callLatency, attrs)
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler.
|
||||
func (h *clientMetricsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler.
|
||||
func (h *clientMetricsHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
|
||||
// TagRPC implements per RPC attempt context management for metrics.
|
||||
func (h *clientMetricsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
|
||||
ctx, ri := getOrCreateClientRPCInfo(ctx, info)
|
||||
// Numerous stats handlers can be used for the same channel. This callback
|
||||
// ensures that all label updates are propagated to the rpc attempt info across
|
||||
// derived contexts.
|
||||
ctx = istats.RegisterTelemetryLabelCallback(ctx, func(labels map[string]string) {
|
||||
maps.Copy(ri.ai.xdsLabels, labels)
|
||||
})
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleRPC handles per RPC stats implementation.
|
||||
func (h *clientMetricsHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := clientRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
logger.Error("ctx passed into client side stats handler metrics event handling has no client attempt data present")
|
||||
return
|
||||
}
|
||||
h.processRPCEvent(ctx, rs, ri.ai)
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) processRPCEvent(ctx context.Context, s stats.RPCStats, ai *attemptInfo) {
|
||||
switch st := s.(type) {
|
||||
case *stats.Begin:
|
||||
ci := getCallInfo(ctx)
|
||||
if ci == nil {
|
||||
logger.Error("ctx passed into client side stats handler metrics event handling has no metrics data present")
|
||||
return
|
||||
}
|
||||
|
||||
attributes := []otelattribute.KeyValue{
|
||||
otelattribute.String("grpc.method", ci.method),
|
||||
otelattribute.String("grpc.target", ci.target),
|
||||
}
|
||||
for _, o := range h.options.MetricsOptions.OptionalLabels {
|
||||
if o == "grpc.client.call.custom" {
|
||||
label := estats.CustomLabelFromContext(ctx)
|
||||
attributes = append(attributes, otelattribute.String(o, label))
|
||||
}
|
||||
}
|
||||
|
||||
attrs := otelmetric.WithAttributeSet(otelattribute.NewSet(attributes...))
|
||||
h.clientMetrics.attemptStarted.Add(ctx, 1, attrs)
|
||||
case *stats.OutPayload:
|
||||
atomic.AddInt64(&ai.sentCompressedBytes, int64(st.CompressedLength))
|
||||
case *stats.InPayload:
|
||||
atomic.AddInt64(&ai.recvCompressedBytes, int64(st.CompressedLength))
|
||||
case *stats.InHeader:
|
||||
h.setLabelsFromPluginOption(ai, st.Header)
|
||||
case *stats.InTrailer:
|
||||
h.setLabelsFromPluginOption(ai, st.Trailer)
|
||||
case *stats.End:
|
||||
h.processRPCEnd(ctx, ai, st)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) setLabelsFromPluginOption(ai *attemptInfo, incomingMetadata metadata.MD) {
|
||||
if ai.pluginOptionLabels == nil && h.options.MetricsOptions.pluginOption != nil {
|
||||
labels := h.options.MetricsOptions.pluginOption.GetLabels(incomingMetadata)
|
||||
if labels == nil {
|
||||
labels = map[string]string{} // Shouldn't return a nil map. Make it empty if so to ignore future Get Calls for this Attempt.
|
||||
}
|
||||
ai.pluginOptionLabels = labels
|
||||
}
|
||||
}
|
||||
|
||||
func (h *clientMetricsHandler) processRPCEnd(ctx context.Context, ai *attemptInfo, e *stats.End) {
|
||||
ci := getCallInfo(ctx)
|
||||
if ci == nil {
|
||||
logger.Error("ctx passed into client side stats handler metrics event handling has no metrics data present")
|
||||
return
|
||||
}
|
||||
latency := float64(time.Since(ai.startTime)) / float64(time.Second)
|
||||
st := "OK"
|
||||
if e.Error != nil {
|
||||
s, _ := status.FromError(e.Error)
|
||||
st = canonicalString(s.Code())
|
||||
}
|
||||
|
||||
attributes := []otelattribute.KeyValue{
|
||||
otelattribute.String("grpc.method", ci.method),
|
||||
otelattribute.String("grpc.target", ci.target),
|
||||
otelattribute.String("grpc.status", st),
|
||||
}
|
||||
|
||||
for k, v := range ai.pluginOptionLabels {
|
||||
attributes = append(attributes, otelattribute.String(k, v))
|
||||
}
|
||||
|
||||
for _, o := range h.options.MetricsOptions.OptionalLabels {
|
||||
// TODO: Add a filter for converting to unknown if not present in the
|
||||
// CSM Plugin Option layer by adding an optional labels API.
|
||||
if val, ok := ai.xdsLabels[o]; ok {
|
||||
attributes = append(attributes, otelattribute.String(o, val))
|
||||
} else if o == "grpc.client.call.custom" {
|
||||
label := estats.CustomLabelFromContext(ctx)
|
||||
attributes = append(attributes, otelattribute.String(o, label))
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate vararg slice once.
|
||||
opts := []otelmetric.RecordOption{otelmetric.WithAttributeSet(otelattribute.NewSet(attributes...))}
|
||||
h.clientMetrics.attemptDuration.Record(ctx, latency, opts...)
|
||||
h.clientMetrics.attemptSentTotalCompressedMessageSize.Record(ctx, atomic.LoadInt64(&ai.sentCompressedBytes), opts...)
|
||||
h.clientMetrics.attemptRcvdTotalCompressedMessageSize.Record(ctx, atomic.LoadInt64(&ai.recvCompressedBytes), opts...)
|
||||
}
|
||||
|
||||
const (
|
||||
// ClientAttemptStartedMetricName is the number of client call attempts
|
||||
// started.
|
||||
ClientAttemptStartedMetricName string = "grpc.client.attempt.started"
|
||||
// ClientAttemptDurationMetricName is the end-to-end time taken to complete
|
||||
// a client call attempt.
|
||||
ClientAttemptDurationMetricName string = "grpc.client.attempt.duration"
|
||||
// ClientAttemptSentCompressedTotalMessageSizeMetricName is the compressed
|
||||
// message bytes sent per client call attempt.
|
||||
ClientAttemptSentCompressedTotalMessageSizeMetricName string = "grpc.client.attempt.sent_total_compressed_message_size"
|
||||
// ClientAttemptRcvdCompressedTotalMessageSizeMetricName is the compressed
|
||||
// message bytes received per call attempt.
|
||||
ClientAttemptRcvdCompressedTotalMessageSizeMetricName string = "grpc.client.attempt.rcvd_total_compressed_message_size"
|
||||
// ClientCallDurationMetricName is the time taken by gRPC to complete an RPC
|
||||
// from application's perspective.
|
||||
ClientCallDurationMetricName string = "grpc.client.call.duration"
|
||||
)
|
||||
155
stats/opentelemetry/client_tracing.go
Normal file
155
stats/opentelemetry/client_tracing.go
Normal file
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
otelcodes "go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
grpccodes "google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/stats"
|
||||
otelinternaltracing "google.golang.org/grpc/stats/opentelemetry/internal/tracing"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
const (
|
||||
delayedResolutionEventName = "Delayed name resolution complete"
|
||||
tracerName = "grpc-go"
|
||||
)
|
||||
|
||||
type clientTracingHandler struct {
|
||||
options Options
|
||||
}
|
||||
|
||||
func (h *clientTracingHandler) initializeTraces() {
|
||||
if h.options.TraceOptions.TracerProvider == nil {
|
||||
log.Printf("TracerProvider is not provided in client TraceOptions")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *clientTracingHandler) unaryInterceptor(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
|
||||
ctx, _ = getOrCreateCallInfo(ctx, cc, method, opts...)
|
||||
|
||||
var span trace.Span
|
||||
ctx, span = h.createCallTraceSpan(ctx, method)
|
||||
err := invoker(ctx, method, req, reply, cc, opts...)
|
||||
h.finishTrace(err, span)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *clientTracingHandler) streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
|
||||
ctx, _ = getOrCreateCallInfo(ctx, cc, method, opts...)
|
||||
|
||||
var span trace.Span
|
||||
ctx, span = h.createCallTraceSpan(ctx, method)
|
||||
callback := func(err error) { h.finishTrace(err, span) }
|
||||
opts = append([]grpc.CallOption{grpc.OnFinish(callback)}, opts...)
|
||||
return streamer(ctx, desc, cc, method, opts...)
|
||||
}
|
||||
|
||||
// finishTrace sets the span status based on the RPC result and ends the span.
|
||||
// It is used to finalize tracing for both unary and streaming calls.
|
||||
func (h *clientTracingHandler) finishTrace(err error, ts trace.Span) {
|
||||
s := status.Convert(err)
|
||||
if s.Code() == grpccodes.OK {
|
||||
ts.SetStatus(otelcodes.Ok, s.Message())
|
||||
} else {
|
||||
ts.SetStatus(otelcodes.Error, s.Message())
|
||||
}
|
||||
ts.End()
|
||||
}
|
||||
|
||||
// traceTagRPC populates provided context with a new span using the
|
||||
// TextMapPropagator supplied in trace options and internal itracing.carrier.
|
||||
// It creates a new outgoing carrier which serializes information about this
|
||||
// span into gRPC Metadata, if TextMapPropagator is provided in the trace
|
||||
// options. if TextMapPropagator is not provided, it returns the context as is.
|
||||
//
|
||||
// Note: The passed attemptInfo pointer (ai) is mutated in-place. Fields such as
|
||||
// ai.traceSpan are updated directly. No new attemptInfo is returned.
|
||||
func (h *clientTracingHandler) traceTagRPC(ctx context.Context, ai *attemptInfo, nameResolutionDelayed bool) context.Context {
|
||||
// Add a "Delayed name resolution complete" event to the call span
|
||||
// if there was name resolution delay. In case of multiple retry attempts,
|
||||
// ensure that event is added only once.
|
||||
callSpan := trace.SpanFromContext(ctx)
|
||||
ci := getCallInfo(ctx)
|
||||
if nameResolutionDelayed && !ci.nameResolutionEventAdded.Swap(true) && callSpan.SpanContext().IsValid() {
|
||||
callSpan.AddEvent(delayedResolutionEventName)
|
||||
}
|
||||
mn := "Attempt." + strings.Replace(ai.method, "/", ".", -1)
|
||||
tracer := h.options.TraceOptions.TracerProvider.Tracer(tracerName, trace.WithInstrumentationVersion(grpc.Version))
|
||||
ctx, span := tracer.Start(ctx, mn)
|
||||
carrier := otelinternaltracing.NewOutgoingCarrier(ctx)
|
||||
h.options.TraceOptions.TextMapPropagator.Inject(ctx, carrier)
|
||||
ai.traceSpan = span
|
||||
return carrier.Context()
|
||||
}
|
||||
|
||||
// createCallTraceSpan creates a call span to put in the provided context using
|
||||
// provided TraceProvider. If TraceProvider is nil, it returns context as is.
|
||||
func (h *clientTracingHandler) createCallTraceSpan(ctx context.Context, method string) (context.Context, trace.Span) {
|
||||
mn := "Sent." + strings.Replace(removeLeadingSlash(method), "/", ".", -1)
|
||||
tracer := h.options.TraceOptions.TracerProvider.Tracer(tracerName, trace.WithInstrumentationVersion(grpc.Version))
|
||||
ctx, span := tracer.Start(ctx, mn, trace.WithSpanKind(trace.SpanKindClient))
|
||||
return ctx, span
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler for tracing.
|
||||
func (h *clientTracingHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler for tracing.
|
||||
func (h *clientTracingHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
|
||||
// TagRPC implements per RPC attempt context management for traces.
|
||||
func (h *clientTracingHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
|
||||
ctx, ri := getOrCreateClientRPCInfo(ctx, info)
|
||||
ci := getCallInfo(ctx)
|
||||
if ci == nil {
|
||||
logger.Error("context passed into client side stats handler (TagRPC) has no call info")
|
||||
return ctx
|
||||
}
|
||||
ctx = h.traceTagRPC(ctx, ri.ai, info.NameResolutionDelay)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleRPC handles per RPC tracing implementation.
|
||||
func (h *clientTracingHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := clientRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
logger.Error("ctx passed into client side tracing handler trace event handling has no client attempt data present")
|
||||
return
|
||||
}
|
||||
|
||||
// Client-specific Begin attributes.
|
||||
if begin, ok := rs.(*stats.Begin); ok {
|
||||
ci := getCallInfo(ctx)
|
||||
previousRPCAttempts := ci.previousRPCAttempts.Add(1) - 1
|
||||
ri.ai.traceSpan.SetAttributes(
|
||||
attribute.Int64("previous-rpc-attempts", int64(previousRPCAttempts)),
|
||||
attribute.Bool("transparent-retry", begin.IsTransparentRetryAttempt),
|
||||
)
|
||||
}
|
||||
populateSpan(rs, ri.ai)
|
||||
}
|
||||
84
stats/opentelemetry/csm/observability.go
Normal file
84
stats/opentelemetry/csm/observability.go
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package csm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/internal"
|
||||
"google.golang.org/grpc/stats/opentelemetry"
|
||||
otelinternal "google.golang.org/grpc/stats/opentelemetry/internal"
|
||||
)
|
||||
|
||||
// EnableObservability sets up CSM Observability for the binary globally.
|
||||
//
|
||||
// The CSM Stats Plugin is instantiated with local labels and metadata exchange
|
||||
// labels pulled from the environment, and emits metadata exchange labels from
|
||||
// the peer and local labels. Context timeouts do not trigger an error, but set
|
||||
// certain labels to "unknown".
|
||||
//
|
||||
// This function is not thread safe, and should only be invoked once in main
|
||||
// before any channels or servers are created. Returns a cleanup function to be
|
||||
// deferred in main.
|
||||
func EnableObservability(ctx context.Context, options opentelemetry.Options) func() {
|
||||
csmPluginOption := newPluginOption(ctx)
|
||||
clientSideOTelWithCSM := dialOptionWithCSMPluginOption(options, csmPluginOption)
|
||||
clientSideOTel := opentelemetry.DialOption(options)
|
||||
internal.AddGlobalPerTargetDialOptions.(func(opt any))(&perTargetDialOption{
|
||||
clientSideOTelWithCSM: clientSideOTelWithCSM,
|
||||
clientSideOTel: clientSideOTel,
|
||||
})
|
||||
|
||||
serverSideOTelWithCSM := serverOptionWithCSMPluginOption(options, csmPluginOption)
|
||||
internal.AddGlobalServerOptions.(func(opt ...grpc.ServerOption))(serverSideOTelWithCSM)
|
||||
|
||||
return func() {
|
||||
internal.ClearGlobalServerOptions()
|
||||
internal.ClearGlobalPerTargetDialOptions()
|
||||
}
|
||||
}
|
||||
|
||||
type perTargetDialOption struct {
|
||||
clientSideOTelWithCSM grpc.DialOption
|
||||
clientSideOTel grpc.DialOption
|
||||
}
|
||||
|
||||
func (o *perTargetDialOption) DialOptionForTarget(parsedTarget url.URL) grpc.DialOption {
|
||||
if determineTargetCSM(&parsedTarget) {
|
||||
return o.clientSideOTelWithCSM
|
||||
}
|
||||
return o.clientSideOTel
|
||||
}
|
||||
|
||||
func dialOptionWithCSMPluginOption(options opentelemetry.Options, po otelinternal.PluginOption) grpc.DialOption {
|
||||
options.MetricsOptions.OptionalLabels = []string{"csm.service_name", "csm.service_namespace_name"} // Attach the two xDS Optional Labels for this component to not filter out.
|
||||
return dialOptionSetCSM(options, po)
|
||||
}
|
||||
|
||||
func dialOptionSetCSM(options opentelemetry.Options, po otelinternal.PluginOption) grpc.DialOption {
|
||||
otelinternal.SetPluginOption.(func(options *opentelemetry.Options, po otelinternal.PluginOption))(&options, po)
|
||||
return opentelemetry.DialOption(options)
|
||||
}
|
||||
|
||||
func serverOptionWithCSMPluginOption(options opentelemetry.Options, po otelinternal.PluginOption) grpc.ServerOption {
|
||||
otelinternal.SetPluginOption.(func(options *opentelemetry.Options, po otelinternal.PluginOption))(&options, po)
|
||||
return opentelemetry.ServerOption(options)
|
||||
}
|
||||
649
stats/opentelemetry/csm/observability_test.go
Normal file
649
stats/opentelemetry/csm/observability_test.go
Normal file
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package csm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/balancer"
|
||||
"google.golang.org/grpc/balancer/base"
|
||||
"google.golang.org/grpc/encoding/gzip"
|
||||
estats "google.golang.org/grpc/experimental/stats"
|
||||
istats "google.golang.org/grpc/internal/stats"
|
||||
"google.golang.org/grpc/internal/stubserver"
|
||||
testgrpc "google.golang.org/grpc/interop/grpc_testing"
|
||||
testpb "google.golang.org/grpc/interop/grpc_testing"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats/opentelemetry"
|
||||
itestutils "google.golang.org/grpc/stats/opentelemetry/internal/testutils"
|
||||
)
|
||||
|
||||
// setupEnv configures the environment for CSM Observability Testing. It sets
|
||||
// the bootstrap env var to a bootstrap file with a nodeID provided. It sets CSM
|
||||
// Env Vars as well, and mocks the resource detector's returned attribute set to
|
||||
// simulate the environment. It registers a cleanup function on the provided t
|
||||
// to restore the environment to its original state.
|
||||
func setupEnv(t *testing.T, resourceDetectorEmissions map[string]string, meshID, csmCanonicalServiceName, csmWorkloadName string) {
|
||||
oldCSMMeshID, csmMeshIDPresent := os.LookupEnv("CSM_MESH_ID")
|
||||
oldCSMCanonicalServiceName, csmCanonicalServiceNamePresent := os.LookupEnv("CSM_CANONICAL_SERVICE_NAME")
|
||||
oldCSMWorkloadName, csmWorkloadNamePresent := os.LookupEnv("CSM_WORKLOAD_NAME")
|
||||
os.Setenv("CSM_MESH_ID", meshID)
|
||||
os.Setenv("CSM_CANONICAL_SERVICE_NAME", csmCanonicalServiceName)
|
||||
os.Setenv("CSM_WORKLOAD_NAME", csmWorkloadName)
|
||||
|
||||
var attributes []attribute.KeyValue
|
||||
for k, v := range resourceDetectorEmissions {
|
||||
attributes = append(attributes, attribute.String(k, v))
|
||||
}
|
||||
// Return the attributes configured as part of the test in place
|
||||
// of reading from resource.
|
||||
attrSet := attribute.NewSet(attributes...)
|
||||
origGetAttrSet := getAttrSetFromResourceDetector
|
||||
getAttrSetFromResourceDetector = func(context.Context) *attribute.Set {
|
||||
return &attrSet
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if csmMeshIDPresent {
|
||||
os.Setenv("CSM_MESH_ID", oldCSMMeshID)
|
||||
} else {
|
||||
os.Unsetenv("CSM_MESH_ID")
|
||||
}
|
||||
if csmCanonicalServiceNamePresent {
|
||||
os.Setenv("CSM_CANONICAL_SERVICE_NAME", oldCSMCanonicalServiceName)
|
||||
} else {
|
||||
os.Unsetenv("CSM_CANONICAL_SERVICE_NAME")
|
||||
}
|
||||
if csmWorkloadNamePresent {
|
||||
os.Setenv("CSM_WORKLOAD_NAME", oldCSMWorkloadName)
|
||||
} else {
|
||||
os.Unsetenv("CSM_WORKLOAD_NAME")
|
||||
}
|
||||
|
||||
getAttrSetFromResourceDetector = origGetAttrSet
|
||||
})
|
||||
}
|
||||
|
||||
// TestCSMPluginOptionUnary tests the CSM Plugin Option and labels. It
|
||||
// configures the environment for the CSM Plugin Option to read from. It then
|
||||
// configures a system with a gRPC Client and gRPC server with the OpenTelemetry
|
||||
// Dial and Server Option configured with a CSM Plugin Option with a certain
|
||||
// unary handler set to induce different ways of setting metadata exchange
|
||||
// labels, and makes a Unary RPC. This RPC should cause certain recording for
|
||||
// each registered metric observed through a Manual Metrics Reader on the
|
||||
// provided OpenTelemetry SDK's Meter Provider. The CSM Labels emitted from the
|
||||
// plugin option should be attached to the relevant metrics.
|
||||
func (s) TestCSMPluginOptionUnary(t *testing.T) {
|
||||
resourceDetectorEmissions := map[string]string{
|
||||
"cloud.platform": "gcp_kubernetes_engine",
|
||||
"cloud.region": "cloud_region_val", // availability_zone isn't present, so this should become location
|
||||
"cloud.account.id": "cloud_account_id_val",
|
||||
"k8s.namespace.name": "k8s_namespace_name_val",
|
||||
"k8s.cluster.name": "k8s_cluster_name_val",
|
||||
}
|
||||
const meshID = "mesh_id"
|
||||
const csmCanonicalServiceName = "csm_canonical_service_name"
|
||||
const csmWorkloadName = "csm_workload_name"
|
||||
setupEnv(t, resourceDetectorEmissions, meshID, csmCanonicalServiceName, csmWorkloadName)
|
||||
|
||||
attributesWant := map[string]string{
|
||||
"csm.workload_canonical_service": csmCanonicalServiceName, // from env
|
||||
"csm.mesh_id": "mesh_id", // from bootstrap env var
|
||||
|
||||
// No xDS Labels - this happens in a test below.
|
||||
|
||||
"csm.remote_workload_type": "gcp_kubernetes_engine",
|
||||
"csm.remote_workload_canonical_service": csmCanonicalServiceName,
|
||||
"csm.remote_workload_project_id": "cloud_account_id_val",
|
||||
"csm.remote_workload_cluster_name": "k8s_cluster_name_val",
|
||||
"csm.remote_workload_namespace_name": "k8s_namespace_name_val",
|
||||
"csm.remote_workload_location": "cloud_region_val",
|
||||
"csm.remote_workload_name": csmWorkloadName,
|
||||
}
|
||||
|
||||
var csmLabels []attribute.KeyValue
|
||||
for k, v := range attributesWant {
|
||||
csmLabels = append(csmLabels, attribute.String(k, v))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
unaryCompressedSize := float64(itestutils.GzipCompressedMessageSize(t, &testpb.SimpleRequest{Payload: &testpb.Payload{Body: make([]byte, 10000)}}))
|
||||
tests := []struct {
|
||||
name string
|
||||
// To test the different operations for Unary RPC's from the interceptor
|
||||
// level that can plumb metadata exchange header in.
|
||||
unaryCallFunc func(ctx context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error)
|
||||
opts itestutils.MetricDataOptions
|
||||
}{
|
||||
{
|
||||
name: "normal-flow",
|
||||
unaryCallFunc: func(_ context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
return &testpb.SimpleResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, len(in.GetPayload().GetBody())),
|
||||
}}, nil
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
UnaryCompressedMessageSize: unaryCompressedSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trailers-only",
|
||||
unaryCallFunc: func(context.Context, *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
return nil, errors.New("some error") // return an error and no message - this triggers trailers only - no messages or headers sent
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
UnaryCallFailed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set-header",
|
||||
unaryCallFunc: func(ctx context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
grpc.SetHeader(ctx, metadata.New(map[string]string{"some-metadata": "some-metadata-val"}))
|
||||
|
||||
return &testpb.SimpleResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, len(in.GetPayload().GetBody())),
|
||||
}}, nil
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
UnaryCompressedMessageSize: unaryCompressedSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send-header",
|
||||
unaryCallFunc: func(ctx context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
grpc.SendHeader(ctx, metadata.New(map[string]string{"some-metadata": "some-metadata-val"}))
|
||||
|
||||
return &testpb.SimpleResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, len(in.GetPayload().GetBody())),
|
||||
}}, nil
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
UnaryCompressedMessageSize: unaryCompressedSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send-msg",
|
||||
unaryCallFunc: func(_ context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
return &testpb.SimpleResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, len(in.GetPayload().GetBody())),
|
||||
}}, nil
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
UnaryCompressedMessageSize: unaryCompressedSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := metric.NewManualReader()
|
||||
provider := metric.NewMeterProvider(metric.WithReader(reader))
|
||||
ss := &stubserver.StubServer{UnaryCallF: test.unaryCallFunc}
|
||||
po := newPluginOption(ctx)
|
||||
sopts := []grpc.ServerOption{
|
||||
serverOptionWithCSMPluginOption(opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(),
|
||||
}}, po),
|
||||
}
|
||||
dopts := []grpc.DialOption{dialOptionWithCSMPluginOption(opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(),
|
||||
OptionalLabels: []string{"csm.service_name", "csm.service_namespace_name"},
|
||||
},
|
||||
}, po)}
|
||||
if err := ss.Start(sopts, dopts...); err != nil {
|
||||
t.Fatalf("Error starting endpoint server: %v", err)
|
||||
}
|
||||
defer ss.Stop()
|
||||
|
||||
var request *testpb.SimpleRequest
|
||||
if test.opts.UnaryCompressedMessageSize != 0 {
|
||||
request = &testpb.SimpleRequest{Payload: &testpb.Payload{
|
||||
Body: make([]byte, 10000),
|
||||
}}
|
||||
}
|
||||
// Make a Unary RPC. These should cause certain metrics to be
|
||||
// emitted, which should be able to be observed through the Metric
|
||||
// Reader.
|
||||
ss.Client.UnaryCall(ctx, request, grpc.UseCompressor(gzip.Name))
|
||||
rm := &metricdata.ResourceMetrics{}
|
||||
reader.Collect(ctx, rm)
|
||||
|
||||
gotMetrics := map[string]metricdata.Metrics{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
gotMetrics[m.Name] = m
|
||||
}
|
||||
}
|
||||
|
||||
opts := test.opts
|
||||
opts.Target = ss.Target
|
||||
wantMetrics := itestutils.MetricDataUnary(opts)
|
||||
gotMetrics = itestutils.WaitForServerMetrics(ctx, t, reader, gotMetrics, wantMetrics)
|
||||
itestutils.CompareMetrics(t, gotMetrics, wantMetrics)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSMPluginOptionStreaming tests the CSM Plugin Option and labels. It
|
||||
// configures the environment for the CSM Plugin Option to read from. It then
|
||||
// configures a system with a gRPC Client and gRPC server with the OpenTelemetry
|
||||
// Dial and Server Option configured with a CSM Plugin Option with a certain
|
||||
// streaming handler set to induce different ways of setting metadata exchange
|
||||
// labels, and makes a Streaming RPC. This RPC should cause certain recording
|
||||
// for each registered metric observed through a Manual Metrics Reader on the
|
||||
// provided OpenTelemetry SDK's Meter Provider. The CSM Labels emitted from the
|
||||
// plugin option should be attached to the relevant metrics.
|
||||
func (s) TestCSMPluginOptionStreaming(t *testing.T) {
|
||||
resourceDetectorEmissions := map[string]string{
|
||||
"cloud.platform": "gcp_kubernetes_engine",
|
||||
"cloud.region": "cloud_region_val", // availability_zone isn't present, so this should become location
|
||||
"cloud.account.id": "cloud_account_id_val",
|
||||
"k8s.namespace.name": "k8s_namespace_name_val",
|
||||
"k8s.cluster.name": "k8s_cluster_name_val",
|
||||
}
|
||||
const meshID = "mesh_id"
|
||||
const csmCanonicalServiceName = "csm_canonical_service_name"
|
||||
const csmWorkloadName = "csm_workload_name"
|
||||
setupEnv(t, resourceDetectorEmissions, meshID, csmCanonicalServiceName, csmWorkloadName)
|
||||
|
||||
attributesWant := map[string]string{
|
||||
"csm.workload_canonical_service": csmCanonicalServiceName, // from env
|
||||
"csm.mesh_id": "mesh_id", // from bootstrap env var
|
||||
|
||||
// No xDS Labels - this happens in a test below.
|
||||
|
||||
"csm.remote_workload_type": "gcp_kubernetes_engine",
|
||||
"csm.remote_workload_canonical_service": csmCanonicalServiceName,
|
||||
"csm.remote_workload_project_id": "cloud_account_id_val",
|
||||
"csm.remote_workload_cluster_name": "k8s_cluster_name_val",
|
||||
"csm.remote_workload_namespace_name": "k8s_namespace_name_val",
|
||||
"csm.remote_workload_location": "cloud_region_val",
|
||||
"csm.remote_workload_name": csmWorkloadName,
|
||||
}
|
||||
|
||||
var csmLabels []attribute.KeyValue
|
||||
for k, v := range attributesWant {
|
||||
csmLabels = append(csmLabels, attribute.String(k, v))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
streamingCompressedSize := float64(itestutils.GzipCompressedMessageSize(t, &testpb.StreamingOutputCallRequest{Payload: &testpb.Payload{Body: make([]byte, 10000)}}))
|
||||
tests := []struct {
|
||||
name string
|
||||
// To test the different operations for Streaming RPC's from the
|
||||
// interceptor level that can plumb metadata exchange header in.
|
||||
streamingCallFunc func(stream testgrpc.TestService_FullDuplexCallServer) error
|
||||
opts itestutils.MetricDataOptions
|
||||
}{
|
||||
{
|
||||
name: "trailers-only",
|
||||
streamingCallFunc: func(stream testgrpc.TestService_FullDuplexCallServer) error {
|
||||
for {
|
||||
if _, err := stream.Recv(); err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set-header",
|
||||
streamingCallFunc: func(stream testgrpc.TestService_FullDuplexCallServer) error {
|
||||
stream.SetHeader(metadata.New(map[string]string{"some-metadata": "some-metadata-val"}))
|
||||
for {
|
||||
if _, err := stream.Recv(); err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send-header",
|
||||
streamingCallFunc: func(stream testgrpc.TestService_FullDuplexCallServer) error {
|
||||
stream.SendHeader(metadata.New(map[string]string{"some-metadata": "some-metadata-val"}))
|
||||
for {
|
||||
if _, err := stream.Recv(); err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send-msg",
|
||||
streamingCallFunc: func(stream testgrpc.TestService_FullDuplexCallServer) error {
|
||||
stream.Send(&testpb.StreamingOutputCallResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, 10000),
|
||||
}})
|
||||
for {
|
||||
if _, err := stream.Recv(); err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
},
|
||||
opts: itestutils.MetricDataOptions{
|
||||
CSMLabels: csmLabels,
|
||||
StreamingCompressedMessageSize: streamingCompressedSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := metric.NewManualReader()
|
||||
provider := metric.NewMeterProvider(metric.WithReader(reader))
|
||||
ss := &stubserver.StubServer{FullDuplexCallF: test.streamingCallFunc}
|
||||
po := newPluginOption(ctx)
|
||||
sopts := []grpc.ServerOption{
|
||||
serverOptionWithCSMPluginOption(opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(),
|
||||
}}, po),
|
||||
}
|
||||
dopts := []grpc.DialOption{dialOptionWithCSMPluginOption(opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(),
|
||||
OptionalLabels: []string{"csm.service_name", "csm.service_namespace_name"},
|
||||
},
|
||||
}, po)}
|
||||
if err := ss.Start(sopts, dopts...); err != nil {
|
||||
t.Fatalf("Error starting endpoint server: %v", err)
|
||||
}
|
||||
defer ss.Stop()
|
||||
|
||||
stream, err := ss.Client.FullDuplexCall(ctx, grpc.UseCompressor(gzip.Name))
|
||||
if err != nil {
|
||||
t.Fatalf("ss.Client.FullDuplexCall failed: %f", err)
|
||||
}
|
||||
|
||||
if test.opts.StreamingCompressedMessageSize != 0 {
|
||||
if err := stream.Send(&testpb.StreamingOutputCallRequest{Payload: &testpb.Payload{
|
||||
Body: make([]byte, 10000),
|
||||
}}); err != nil {
|
||||
t.Fatalf("stream.Send failed")
|
||||
}
|
||||
if _, err := stream.Recv(); err != nil {
|
||||
t.Fatalf("stream.Recv failed with error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
stream.CloseSend()
|
||||
if _, err = stream.Recv(); err != io.EOF {
|
||||
t.Fatalf("stream.Recv received an unexpected error: %v, expected an EOF error", err)
|
||||
}
|
||||
|
||||
rm := &metricdata.ResourceMetrics{}
|
||||
reader.Collect(ctx, rm)
|
||||
|
||||
gotMetrics := map[string]metricdata.Metrics{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
gotMetrics[m.Name] = m
|
||||
}
|
||||
}
|
||||
|
||||
opts := test.opts
|
||||
opts.Target = ss.Target
|
||||
wantMetrics := itestutils.MetricDataStreaming(opts)
|
||||
gotMetrics = itestutils.WaitForServerMetrics(ctx, t, reader, gotMetrics, wantMetrics)
|
||||
itestutils.CompareMetrics(t, gotMetrics, wantMetrics)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestXDSLabels tests that xDS Labels get emitted from OpenTelemetry metrics.
|
||||
// This test configures OpenTelemetry with the CSM Plugin Option, and xDS
|
||||
// Optional Labels turned on. It then configures a test balancer that updates
|
||||
// labels, simulating the cluster_impl picker. It then makes a unary RPC, and
|
||||
// expects xDS Labels labels to be attached to emitted relevant metrics. Full
|
||||
// xDS System alongside OpenTelemetry will be tested with interop. (there is a
|
||||
// test for xDS -> Stats handler and this tests -> OTel -> emission). It also
|
||||
// tests the optional per call locality label in the same manner.
|
||||
func (s) TestXDSLabels(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
balancerName := "o11y_balancer_xds_labels"
|
||||
balancer.Register(base.NewBalancerBuilder(balancerName, &o11yPickerBuilder{}, base.Config{}))
|
||||
reader := metric.NewManualReader()
|
||||
provider := metric.NewMeterProvider(metric.WithReader(reader))
|
||||
ss := &stubserver.StubServer{
|
||||
UnaryCallF: func(_ context.Context, in *testpb.SimpleRequest) (*testpb.SimpleResponse, error) {
|
||||
return &testpb.SimpleResponse{Payload: &testpb.Payload{
|
||||
Body: make([]byte, len(in.GetPayload().GetBody())),
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
|
||||
po := newPluginOption(ctx)
|
||||
sc := fmt.Sprintf(`{"loadBalancingConfig": [{"%s": {}}]}`, balancerName)
|
||||
dopts := []grpc.DialOption{dialOptionSetCSM(opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(),
|
||||
OptionalLabels: []string{"csm.service_name", "csm.service_namespace_name", "grpc.lb.locality", "grpc.lb.backend_service", "grpc.client.call.custom"},
|
||||
},
|
||||
}, po), grpc.WithDefaultServiceConfig(sc)}
|
||||
if err := ss.Start(nil, dopts...); err != nil {
|
||||
t.Fatalf("Error starting endpoint server: %v", err)
|
||||
}
|
||||
|
||||
defer ss.Stop()
|
||||
ss.Client.UnaryCall(estats.NewContextWithCustomLabel(ctx, "my-custom-label"), &testpb.SimpleRequest{Payload: &testpb.Payload{
|
||||
Body: make([]byte, 10000),
|
||||
}}, grpc.UseCompressor(gzip.Name))
|
||||
|
||||
rm := &metricdata.ResourceMetrics{}
|
||||
reader.Collect(ctx, rm)
|
||||
|
||||
gotMetrics := map[string]metricdata.Metrics{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
gotMetrics[m.Name] = m
|
||||
}
|
||||
}
|
||||
|
||||
unaryMethodAttr := attribute.String("grpc.method", "grpc.testing.TestService/UnaryCall")
|
||||
targetAttr := attribute.String("grpc.target", ss.Target)
|
||||
unaryStatusAttr := attribute.String("grpc.status", "OK")
|
||||
|
||||
serviceNameAttr := attribute.String("csm.service_name", "service_name_val")
|
||||
serviceNamespaceAttr := attribute.String("csm.service_namespace_name", "service_namespace_val")
|
||||
localityAttr := attribute.String("grpc.lb.locality", "grpc.lb.locality_val")
|
||||
backendServiceAttr := attribute.String("grpc.lb.backend_service", "grpc.lb.backend_service_val")
|
||||
meshIDAttr := attribute.String("csm.mesh_id", "unknown")
|
||||
workloadCanonicalServiceAttr := attribute.String("csm.workload_canonical_service", "unknown")
|
||||
remoteWorkloadTypeAttr := attribute.String("csm.remote_workload_type", "unknown")
|
||||
remoteWorkloadCanonicalServiceAttr := attribute.String("csm.remote_workload_canonical_service", "unknown")
|
||||
customLabelAttr := attribute.String("grpc.client.call.custom", "my-custom-label")
|
||||
|
||||
unaryMethodClientSideEnd := []attribute.KeyValue{
|
||||
unaryMethodAttr,
|
||||
targetAttr,
|
||||
unaryStatusAttr,
|
||||
serviceNameAttr,
|
||||
serviceNamespaceAttr,
|
||||
localityAttr,
|
||||
backendServiceAttr,
|
||||
meshIDAttr,
|
||||
workloadCanonicalServiceAttr,
|
||||
remoteWorkloadTypeAttr,
|
||||
remoteWorkloadCanonicalServiceAttr,
|
||||
customLabelAttr,
|
||||
}
|
||||
|
||||
unaryCompressedBytesSentRecv := int64(itestutils.GzipCompressedMessageSize(t, &testpb.SimpleRequest{
|
||||
Payload: &testpb.Payload{
|
||||
Body: make([]byte, 10000),
|
||||
},
|
||||
})) // Fixed 10000 bytes with gzip.
|
||||
unaryBucketCounts := []uint64{0x0, 0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}
|
||||
unaryExtrema := metricdata.NewExtrema(unaryCompressedBytesSentRecv)
|
||||
wantMetrics := []metricdata.Metrics{
|
||||
{
|
||||
Name: "grpc.client.attempt.started",
|
||||
Description: "Number of client call attempts started.",
|
||||
Unit: "{attempt}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodAttr, targetAttr, customLabelAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
}, // Doesn't have xDS Labels, CSM Labels start from header or trailer from server, whichever comes first, so this doesn't need it
|
||||
{
|
||||
Name: "grpc.client.attempt.duration",
|
||||
Description: "End-to-end time taken to complete a client call attempt.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: itestutils.DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.sent_total_compressed_message_size",
|
||||
Description: "Compressed message bytes sent per client call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: itestutils.DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.rcvd_total_compressed_message_size",
|
||||
Description: "Compressed message bytes received per call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: itestutils.DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.call.duration",
|
||||
Description: "Time taken by gRPC to complete an RPC from application's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodAttr, targetAttr, unaryStatusAttr, customLabelAttr),
|
||||
Count: 1,
|
||||
Bounds: itestutils.DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
gotMetrics = itestutils.WaitForServerMetrics(ctx, t, reader, gotMetrics, wantMetrics)
|
||||
itestutils.CompareMetrics(t, gotMetrics, wantMetrics)
|
||||
}
|
||||
|
||||
// TestObservability tests that Observability global function compiles and runs
|
||||
// without error. The actual functionality of this function will be verified in
|
||||
// interop tests.
|
||||
func (s) TestObservability(*testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
|
||||
cleanup := EnableObservability(ctx, opentelemetry.Options{})
|
||||
cleanup()
|
||||
}
|
||||
|
||||
type o11yPickerBuilder struct{}
|
||||
|
||||
func (*o11yPickerBuilder) Build(info base.PickerBuildInfo) balancer.Picker {
|
||||
return &o11yPicker{info: info}
|
||||
}
|
||||
|
||||
type o11yPicker struct {
|
||||
info base.PickerBuildInfo
|
||||
}
|
||||
|
||||
func (p *o11yPicker) Pick(info balancer.PickInfo) (balancer.PickResult, error) {
|
||||
// This simulates the behavior of the cluster_impl picker.
|
||||
// It runs after TagRPC has already registered the default telemetry
|
||||
// callback.
|
||||
istats.UpdateLabels(info.Ctx, map[string]string{
|
||||
"csm.service_name": "service_name_val",
|
||||
"csm.service_namespace_name": "service_namespace_val",
|
||||
|
||||
"grpc.lb.locality": "grpc.lb.locality_val",
|
||||
"grpc.lb.backend_service": "grpc.lb.backend_service_val",
|
||||
})
|
||||
|
||||
for sc := range p.info.ReadySCs {
|
||||
return balancer.PickResult{SubConn: sc}, nil
|
||||
}
|
||||
return balancer.PickResult{}, balancer.ErrNoSubConnAvailable
|
||||
}
|
||||
279
stats/opentelemetry/csm/pluginoption.go
Normal file
279
stats/opentelemetry/csm/pluginoption.go
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
// Package csm contains utilities for Google Cloud Service Mesh observability.
|
||||
package csm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"os"
|
||||
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats/opentelemetry/internal"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
|
||||
"go.opentelemetry.io/contrib/detectors/gcp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
)
|
||||
|
||||
var logger = grpclog.Component("csm-observability-plugin")
|
||||
|
||||
// pluginOption emits CSM Labels from the environment and metadata exchange
|
||||
// for csm channels and all servers.
|
||||
//
|
||||
// Do not use this directly; use newPluginOption instead.
|
||||
type pluginOption struct {
|
||||
// localLabels are the labels that identify the local environment a binary
|
||||
// is run in, and will be emitted from the CSM Plugin Option.
|
||||
localLabels map[string]string
|
||||
// metadataExchangeLabelsEncoded are the metadata exchange labels to be sent
|
||||
// as the value of metadata key "x-envoy-peer-metadata" in proto wire format
|
||||
// and base 64 encoded. This gets sent out from all the servers running in
|
||||
// this process and for csm channels.
|
||||
metadataExchangeLabelsEncoded string
|
||||
}
|
||||
|
||||
// newPluginOption returns a new pluginOption with local labels and metadata
|
||||
// exchange labels derived from the environment.
|
||||
func newPluginOption(ctx context.Context) internal.PluginOption {
|
||||
localLabels, metadataExchangeLabelsEncoded := constructMetadataFromEnv(ctx)
|
||||
|
||||
return &pluginOption{
|
||||
localLabels: localLabels,
|
||||
metadataExchangeLabelsEncoded: metadataExchangeLabelsEncoded,
|
||||
}
|
||||
}
|
||||
|
||||
// NewLabelsMD returns a metadata.MD with the CSM labels as an encoded protobuf
|
||||
// Struct as the value of "x-envoy-peer-metadata".
|
||||
func (cpo *pluginOption) GetMetadata() metadata.MD {
|
||||
return metadata.New(map[string]string{
|
||||
metadataExchangeKey: cpo.metadataExchangeLabelsEncoded,
|
||||
})
|
||||
}
|
||||
|
||||
// GetLabels gets the CSM peer labels from the metadata provided. It returns
|
||||
// "unknown" for labels not found. Labels returned depend on the remote type.
|
||||
// Additionally, local labels determined at initialization time are appended to
|
||||
// labels returned, in addition to the optionalLabels provided.
|
||||
func (cpo *pluginOption) GetLabels(md metadata.MD) map[string]string {
|
||||
labels := map[string]string{ // Remote labels if type is unknown (i.e. unset or error processing x-envoy-peer-metadata)
|
||||
"csm.remote_workload_type": "unknown",
|
||||
"csm.remote_workload_canonical_service": "unknown",
|
||||
}
|
||||
// Append the local labels.
|
||||
for k, v := range cpo.localLabels {
|
||||
labels[k] = v
|
||||
}
|
||||
|
||||
val := md.Get("x-envoy-peer-metadata")
|
||||
// This can't happen if corresponding csm client because of proto wire
|
||||
// format encoding, but since it is arbitrary off the wire be safe.
|
||||
if len(val) != 1 {
|
||||
logger.Warningf("length of md values of \"x-envoy-peer-metadata\" is not 1, is %v", len(val))
|
||||
return labels
|
||||
}
|
||||
|
||||
protoWireFormat, err := base64.RawStdEncoding.DecodeString(val[0])
|
||||
if err != nil {
|
||||
logger.Warningf("error base 64 decoding value of \"x-envoy-peer-metadata\": %v", err)
|
||||
return labels
|
||||
}
|
||||
|
||||
spb := &structpb.Struct{}
|
||||
if err := proto.Unmarshal(protoWireFormat, spb); err != nil {
|
||||
logger.Warningf("error unmarshalling value of \"x-envoy-peer-metadata\" into proto: %v", err)
|
||||
return labels
|
||||
}
|
||||
|
||||
fields := spb.GetFields()
|
||||
|
||||
labels["csm.remote_workload_type"] = getFromMetadata("type", fields)
|
||||
// The value of “csm.remote_workload_canonical_service” comes from
|
||||
// MetadataExchange with the key “canonical_service”. (Note that this should
|
||||
// be read even if the remote type is unknown.)
|
||||
labels["csm.remote_workload_canonical_service"] = getFromMetadata("canonical_service", fields)
|
||||
|
||||
// Unset/unknown types, and types that aren't GKE or GCP return early with
|
||||
// just local labels, remote_workload_type and
|
||||
// remote_workload_canonical_service labels.
|
||||
workloadType := labels["csm.remote_workload_type"]
|
||||
if workloadType != "gcp_kubernetes_engine" && workloadType != "gcp_compute_engine" {
|
||||
return labels
|
||||
}
|
||||
// GKE and GCE labels.
|
||||
labels["csm.remote_workload_project_id"] = getFromMetadata("project_id", fields)
|
||||
labels["csm.remote_workload_location"] = getFromMetadata("location", fields)
|
||||
labels["csm.remote_workload_name"] = getFromMetadata("workload_name", fields)
|
||||
if workloadType == "gcp_compute_engine" {
|
||||
return labels
|
||||
}
|
||||
|
||||
// GKE only labels.
|
||||
labels["csm.remote_workload_cluster_name"] = getFromMetadata("cluster_name", fields)
|
||||
labels["csm.remote_workload_namespace_name"] = getFromMetadata("namespace_name", fields)
|
||||
return labels
|
||||
}
|
||||
|
||||
// getFromMetadata gets the value for the metadata key from the protobuf
|
||||
// metadata. Returns "unknown" if the metadata is not found in the protobuf
|
||||
// metadata, or if the value is not a string value. Returns the string value
|
||||
// otherwise.
|
||||
func getFromMetadata(metadataKey string, metadata map[string]*structpb.Value) string {
|
||||
if metadata != nil {
|
||||
if metadataVal, ok := metadata[metadataKey]; ok {
|
||||
if _, ok := metadataVal.GetKind().(*structpb.Value_StringValue); ok {
|
||||
return metadataVal.GetStringValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getFromResource gets the value for the resource key from the attribute set.
|
||||
// Returns "unknown" if the resourceKey is not found in the attribute set or is
|
||||
// not a string value, the string value otherwise.
|
||||
func getFromResource(resourceKey attribute.Key, set *attribute.Set) string {
|
||||
if set != nil {
|
||||
if resourceVal, ok := set.Value(resourceKey); ok && resourceVal.Type() == attribute.STRING {
|
||||
return resourceVal.AsString()
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// getEnv returns "unknown" if environment variable is unset, the environment
|
||||
// variable otherwise.
|
||||
func getEnv(name string) string {
|
||||
if val, ok := os.LookupEnv(name); ok {
|
||||
return val
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
var (
|
||||
// This function will be overridden in unit tests.
|
||||
getAttrSetFromResourceDetector = func(ctx context.Context) *attribute.Set {
|
||||
r, err := resource.New(ctx, resource.WithFromEnv(), resource.WithDetectors(gcp.NewDetector()))
|
||||
if err != nil {
|
||||
logger.Warningf("error reading OpenTelemetry resource: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
// It's possible for resource.New to return partial data alongside
|
||||
// an error. In this case, use partial data and set "unknown" for
|
||||
// labels missing.
|
||||
return r.Set()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
||||
// constructMetadataFromEnv creates local labels and labels to send to the peer
|
||||
// using metadata exchange based off resource detection and environment
|
||||
// variables.
|
||||
//
|
||||
// Returns local labels, and base 64 encoded protobuf.Struct containing metadata
|
||||
// exchange labels.
|
||||
func constructMetadataFromEnv(ctx context.Context) (map[string]string, string) {
|
||||
set := getAttrSetFromResourceDetector(ctx)
|
||||
|
||||
labels := make(map[string]string)
|
||||
labels["type"] = getFromResource("cloud.platform", set)
|
||||
labels["canonical_service"] = getEnv("CSM_CANONICAL_SERVICE_NAME")
|
||||
|
||||
// If type is not GCE or GKE only metadata exchange labels are "type" and
|
||||
// "canonical_service".
|
||||
cloudPlatformVal := labels["type"]
|
||||
if cloudPlatformVal != "gcp_kubernetes_engine" && cloudPlatformVal != "gcp_compute_engine" {
|
||||
return initializeLocalAndMetadataLabels(labels)
|
||||
}
|
||||
|
||||
// GCE and GKE labels:
|
||||
labels["workload_name"] = getEnv("CSM_WORKLOAD_NAME")
|
||||
|
||||
locationVal := "unknown"
|
||||
if resourceVal, ok := set.Value("cloud.availability_zone"); ok && resourceVal.Type() == attribute.STRING {
|
||||
locationVal = resourceVal.AsString()
|
||||
} else if resourceVal, ok = set.Value("cloud.region"); ok && resourceVal.Type() == attribute.STRING {
|
||||
locationVal = resourceVal.AsString()
|
||||
}
|
||||
labels["location"] = locationVal
|
||||
|
||||
labels["project_id"] = getFromResource("cloud.account.id", set)
|
||||
if cloudPlatformVal == "gcp_compute_engine" {
|
||||
return initializeLocalAndMetadataLabels(labels)
|
||||
}
|
||||
|
||||
// GKE specific labels:
|
||||
labels["namespace_name"] = getFromResource("k8s.namespace.name", set)
|
||||
labels["cluster_name"] = getFromResource("k8s.cluster.name", set)
|
||||
return initializeLocalAndMetadataLabels(labels)
|
||||
}
|
||||
|
||||
// initializeLocalAndMetadataLabels csm local labels for a CSM Plugin Option to
|
||||
// record. It also builds out a base 64 encoded protobuf.Struct containing the
|
||||
// metadata exchange labels to be sent as part of metadata exchange from a CSM
|
||||
// Plugin Option.
|
||||
func initializeLocalAndMetadataLabels(labels map[string]string) (map[string]string, string) {
|
||||
// The value of “csm.workload_canonical_service” comes from
|
||||
// “CSM_CANONICAL_SERVICE_NAME” env var, “unknown” if unset.
|
||||
val := labels["canonical_service"]
|
||||
localLabels := make(map[string]string)
|
||||
localLabels["csm.workload_canonical_service"] = val
|
||||
localLabels["csm.mesh_id"] = getEnv("CSM_MESH_ID")
|
||||
|
||||
// Metadata exchange labels - can go ahead and encode into proto, and then
|
||||
// base64.
|
||||
pbLabels := &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{},
|
||||
}
|
||||
for k, v := range labels {
|
||||
pbLabels.Fields[k] = structpb.NewStringValue(v)
|
||||
}
|
||||
protoWireFormat, err := proto.Marshal(pbLabels)
|
||||
metadataExchangeLabelsEncoded := ""
|
||||
if err == nil {
|
||||
metadataExchangeLabelsEncoded = base64.RawStdEncoding.EncodeToString(protoWireFormat)
|
||||
}
|
||||
// else - This behavior triggers server side to reply (if sent from a gRPC
|
||||
// Client within this binary) with the metadata exchange labels. Even if
|
||||
// client side has a problem marshaling proto into wire format, it can
|
||||
// still use server labels so send an empty string as the value of
|
||||
// x-envoy-peer-metadata. The presence of this metadata exchange header
|
||||
// will cause server side to respond with metadata exchange labels.
|
||||
|
||||
return localLabels, metadataExchangeLabelsEncoded
|
||||
}
|
||||
|
||||
// metadataExchangeKey is the key for HTTP metadata exchange.
|
||||
const metadataExchangeKey = "x-envoy-peer-metadata"
|
||||
|
||||
func determineTargetCSM(parsedTarget *url.URL) bool {
|
||||
// On the client-side, the channel target is used to determine if a channel is a
|
||||
// CSM channel or not. CSM channels need to have an “xds” scheme and a
|
||||
// "traffic-director-global.xds.googleapis.com" authority. In the cases where no
|
||||
// authority is mentioned, the authority is assumed to be CSM. MetadataExchange
|
||||
// is performed only for CSM channels. Non-metadata exchange labels are detected
|
||||
// as described below.
|
||||
return parsedTarget.Scheme == "xds" && (parsedTarget.Host == "" || parsedTarget.Host == "traffic-director-global.xds.googleapis.com")
|
||||
}
|
||||
490
stats/opentelemetry/csm/pluginoption_test.go
Normal file
490
stats/opentelemetry/csm/pluginoption_test.go
Normal file
@@ -0,0 +1,490 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package csm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/internal/envconfig"
|
||||
"google.golang.org/grpc/internal/grpctest"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
"google.golang.org/protobuf/types/known/structpb"
|
||||
)
|
||||
|
||||
type s struct {
|
||||
grpctest.Tester
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
grpctest.RunSubTests(t, s{})
|
||||
}
|
||||
|
||||
var defaultTestTimeout = 5 * time.Second
|
||||
|
||||
// clearEnv unsets all the environment variables relevant to the csm
|
||||
// pluginOption.
|
||||
func clearEnv() {
|
||||
os.Unsetenv(envconfig.XDSBootstrapFileContentEnv)
|
||||
os.Unsetenv(envconfig.XDSBootstrapFileNameEnv)
|
||||
|
||||
os.Unsetenv("CSM_CANONICAL_SERVICE_NAME")
|
||||
os.Unsetenv("CSM_WORKLOAD_NAME")
|
||||
}
|
||||
|
||||
func (s) TestGetLabels(t *testing.T) {
|
||||
clearEnv()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
cpo := newPluginOption(ctx)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
unsetHeader bool // Should trigger "unknown" labels
|
||||
twoValues bool // Should trigger "unknown" labels
|
||||
metadataExchangeLabels map[string]string
|
||||
labelsWant map[string]string
|
||||
}{
|
||||
{
|
||||
name: "unset-labels",
|
||||
metadataExchangeLabels: nil,
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "unknown",
|
||||
"csm.remote_workload_canonical_service": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "metadata-partially-set",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "not-gce-or-gke",
|
||||
"ignore-this": "ignore-this",
|
||||
},
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "not-gce-or-gke",
|
||||
"csm.remote_workload_canonical_service": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "google-compute-engine",
|
||||
metadataExchangeLabels: map[string]string{ // All of these labels get emitted when type is "gcp_compute_engine".
|
||||
"type": "gcp_compute_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"location": "us-east",
|
||||
"workload_name": "workload_name_val",
|
||||
},
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "gcp_compute_engine",
|
||||
"csm.remote_workload_canonical_service": "canonical_service_val",
|
||||
"csm.remote_workload_project_id": "unique-id",
|
||||
"csm.remote_workload_location": "us-east",
|
||||
"csm.remote_workload_name": "workload_name_val",
|
||||
},
|
||||
},
|
||||
// unset should go to unknown, ignore GKE labels that are not relevant
|
||||
// to GCE.
|
||||
{
|
||||
name: "google-compute-engine-labels-partially-set-with-extra",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "gcp_compute_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"location": "us-east",
|
||||
// "workload_name": "", unset workload name - should become "unknown"
|
||||
"namespace_name": "should-be-ignored",
|
||||
"cluster_name": "should-be-ignored",
|
||||
},
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "gcp_compute_engine",
|
||||
"csm.remote_workload_canonical_service": "canonical_service_val",
|
||||
"csm.remote_workload_project_id": "unique-id",
|
||||
"csm.remote_workload_location": "us-east",
|
||||
"csm.remote_workload_name": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "google-kubernetes-engine",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"namespace_name": "namespace_name_val",
|
||||
"cluster_name": "cluster_name_val",
|
||||
"location": "us-east",
|
||||
"workload_name": "workload_name_val",
|
||||
},
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "gcp_kubernetes_engine",
|
||||
"csm.remote_workload_canonical_service": "canonical_service_val",
|
||||
"csm.remote_workload_project_id": "unique-id",
|
||||
"csm.remote_workload_cluster_name": "cluster_name_val",
|
||||
"csm.remote_workload_namespace_name": "namespace_name_val",
|
||||
"csm.remote_workload_location": "us-east",
|
||||
"csm.remote_workload_name": "workload_name_val",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "google-kubernetes-engine-labels-partially-set",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"namespace_name": "namespace_name_val",
|
||||
// "cluster_name": "", cluster_name unset, should become "unknown"
|
||||
"location": "us-east",
|
||||
// "workload_name": "", workload_name unset, should become "unknown"
|
||||
},
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "gcp_kubernetes_engine",
|
||||
"csm.remote_workload_canonical_service": "canonical_service_val",
|
||||
"csm.remote_workload_project_id": "unique-id",
|
||||
"csm.remote_workload_cluster_name": "unknown",
|
||||
"csm.remote_workload_namespace_name": "namespace_name_val",
|
||||
"csm.remote_workload_location": "us-east",
|
||||
"csm.remote_workload_name": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unset-header",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"namespace_name": "namespace_name_val",
|
||||
"cluster_name": "cluster_name_val",
|
||||
"location": "us-east",
|
||||
"workload_name": "workload_name_val",
|
||||
},
|
||||
unsetHeader: true,
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "unknown",
|
||||
"csm.remote_workload_canonical_service": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "two-header-values",
|
||||
metadataExchangeLabels: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "canonical_service_val",
|
||||
"project_id": "unique-id",
|
||||
"namespace_name": "namespace_name_val",
|
||||
"cluster_name": "cluster_name_val",
|
||||
"location": "us-east",
|
||||
"workload_name": "workload_name_val",
|
||||
},
|
||||
twoValues: true,
|
||||
labelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
|
||||
"csm.remote_workload_type": "unknown",
|
||||
"csm.remote_workload_canonical_service": "unknown",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
pbLabels := &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{},
|
||||
}
|
||||
for k, v := range test.metadataExchangeLabels {
|
||||
pbLabels.Fields[k] = structpb.NewStringValue(v)
|
||||
}
|
||||
protoWireFormat, err := proto.Marshal(pbLabels)
|
||||
if err != nil {
|
||||
t.Fatalf("Error marshaling proto: %v", err)
|
||||
}
|
||||
metadataExchangeLabelsEncoded := base64.RawStdEncoding.EncodeToString(protoWireFormat)
|
||||
md := metadata.New(map[string]string{
|
||||
metadataExchangeKey: metadataExchangeLabelsEncoded,
|
||||
})
|
||||
if test.unsetHeader {
|
||||
md.Delete(metadataExchangeKey)
|
||||
}
|
||||
if test.twoValues {
|
||||
md.Append(metadataExchangeKey, "extra-val")
|
||||
}
|
||||
|
||||
labelsGot := cpo.GetLabels(md)
|
||||
if diff := cmp.Diff(labelsGot, test.labelsWant); diff != "" {
|
||||
t.Fatalf("cpo.GetLabels returned unexpected value (-got, +want): %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetermineTargetCSM tests the helper function that determines whether a
|
||||
// target is relevant to CSM or not, based off the rules outlined in design.
|
||||
func (s) TestDetermineTargetCSM(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
targetCSM bool
|
||||
}{
|
||||
{
|
||||
name: "dns:///localhost",
|
||||
target: "normal-target-here",
|
||||
targetCSM: false,
|
||||
},
|
||||
{
|
||||
name: "xds-no-authority",
|
||||
target: "xds:///localhost",
|
||||
targetCSM: true,
|
||||
},
|
||||
{
|
||||
name: "xds-traffic-director-authority",
|
||||
target: "xds://traffic-director-global.xds.googleapis.com/localhost",
|
||||
targetCSM: true,
|
||||
},
|
||||
{
|
||||
name: "xds-not-traffic-director-authority",
|
||||
target: "xds://not-traffic-director-authority/localhost",
|
||||
targetCSM: false,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
parsedTarget, err := url.Parse(test.target)
|
||||
if err != nil {
|
||||
t.Fatalf("test target %v failed to parse: %v", test.target, err)
|
||||
}
|
||||
if got := determineTargetCSM(parsedTarget); got != test.targetCSM {
|
||||
t.Fatalf("cpo.determineTargetCSM(%v): got %v, want %v", test.target, got, test.targetCSM)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetLabels tests the setting of labels, which snapshots the resource and
|
||||
// environment. It mocks the resource and environment, and then calls into
|
||||
// labels creation. It verifies to local labels created and metadata exchange
|
||||
// labels emitted from the setLabels function.
|
||||
func (s) TestSetLabels(t *testing.T) {
|
||||
clearEnv()
|
||||
tests := []struct {
|
||||
name string
|
||||
resourceKeyValues map[string]string
|
||||
csmCanonicalServiceNamePopulated bool
|
||||
csmWorkloadNamePopulated bool
|
||||
meshIDPopulated bool
|
||||
localLabelsWant map[string]string
|
||||
metadataExchangeLabelsWant map[string]string
|
||||
}{
|
||||
{
|
||||
name: "no-type",
|
||||
csmCanonicalServiceNamePopulated: true,
|
||||
meshIDPopulated: true,
|
||||
resourceKeyValues: map[string]string{},
|
||||
localLabelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "canonical_service_name_val", // env var populated so should be set.
|
||||
"csm.mesh_id": "mesh_id", // env var populated so should be set.
|
||||
},
|
||||
metadataExchangeLabelsWant: map[string]string{
|
||||
"type": "unknown",
|
||||
"canonical_service": "canonical_service_name_val", // env var populated so should be set.
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gce",
|
||||
csmWorkloadNamePopulated: true,
|
||||
resourceKeyValues: map[string]string{
|
||||
"cloud.platform": "gcp_compute_engine",
|
||||
// csm workload name is an env var
|
||||
"cloud.availability_zone": "cloud_availability_zone_val",
|
||||
"cloud.region": "should-be-ignored", // cloud.availability_zone takes precedence
|
||||
"cloud.account.id": "cloud_account_id_val",
|
||||
},
|
||||
localLabelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
},
|
||||
metadataExchangeLabelsWant: map[string]string{
|
||||
"type": "gcp_compute_engine",
|
||||
"canonical_service": "unknown",
|
||||
"workload_name": "workload_name_val",
|
||||
"location": "cloud_availability_zone_val",
|
||||
"project_id": "cloud_account_id_val",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gce-half-unset",
|
||||
resourceKeyValues: map[string]string{
|
||||
"cloud.platform": "gcp_compute_engine",
|
||||
// csm workload name is an env var
|
||||
"cloud.availability_zone": "cloud_availability_zone_val",
|
||||
"cloud.region": "should-be-ignored", // cloud.availability_zone takes precedence
|
||||
},
|
||||
localLabelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
},
|
||||
metadataExchangeLabelsWant: map[string]string{
|
||||
"type": "gcp_compute_engine",
|
||||
"canonical_service": "unknown",
|
||||
"workload_name": "unknown",
|
||||
"location": "cloud_availability_zone_val",
|
||||
"project_id": "unknown",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gke",
|
||||
resourceKeyValues: map[string]string{
|
||||
"cloud.platform": "gcp_kubernetes_engine",
|
||||
// csm workload name is an env var
|
||||
"cloud.region": "cloud_region_val", // availability_zone isn't present, so this should become location
|
||||
"cloud.account.id": "cloud_account_id_val",
|
||||
"k8s.namespace.name": "k8s_namespace_name_val",
|
||||
"k8s.cluster.name": "k8s_cluster_name_val",
|
||||
},
|
||||
localLabelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
},
|
||||
metadataExchangeLabelsWant: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "unknown",
|
||||
"workload_name": "unknown",
|
||||
"location": "cloud_region_val",
|
||||
"project_id": "cloud_account_id_val",
|
||||
"namespace_name": "k8s_namespace_name_val",
|
||||
"cluster_name": "k8s_cluster_name_val",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gke-half-unset",
|
||||
resourceKeyValues: map[string]string{ // unset should become unknown
|
||||
"cloud.platform": "gcp_kubernetes_engine",
|
||||
// csm workload name is an env var
|
||||
"cloud.region": "cloud_region_val", // availability_zone isn't present, so this should become location
|
||||
// "cloud.account.id": "", // unset - should become unknown
|
||||
"k8s.namespace.name": "k8s_namespace_name_val",
|
||||
// "k8s.cluster.name": "", // unset - should become unknown
|
||||
},
|
||||
localLabelsWant: map[string]string{
|
||||
"csm.workload_canonical_service": "unknown",
|
||||
"csm.mesh_id": "unknown",
|
||||
},
|
||||
metadataExchangeLabelsWant: map[string]string{
|
||||
"type": "gcp_kubernetes_engine",
|
||||
"canonical_service": "unknown",
|
||||
"workload_name": "unknown",
|
||||
"location": "cloud_region_val",
|
||||
"project_id": "unknown",
|
||||
"namespace_name": "k8s_namespace_name_val",
|
||||
"cluster_name": "unknown",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
func() {
|
||||
if test.csmCanonicalServiceNamePopulated {
|
||||
os.Setenv("CSM_CANONICAL_SERVICE_NAME", "canonical_service_name_val")
|
||||
defer os.Unsetenv("CSM_CANONICAL_SERVICE_NAME")
|
||||
}
|
||||
if test.csmWorkloadNamePopulated {
|
||||
os.Setenv("CSM_WORKLOAD_NAME", "workload_name_val")
|
||||
defer os.Unsetenv("CSM_WORKLOAD_NAME")
|
||||
}
|
||||
if test.meshIDPopulated {
|
||||
os.Setenv("CSM_MESH_ID", "mesh_id")
|
||||
defer os.Unsetenv("CSM_MESH_ID")
|
||||
}
|
||||
var attributes []attribute.KeyValue
|
||||
for k, v := range test.resourceKeyValues {
|
||||
attributes = append(attributes, attribute.String(k, v))
|
||||
}
|
||||
// Return the attributes configured as part of the test in place
|
||||
// of reading from resource.
|
||||
attrSet := attribute.NewSet(attributes...)
|
||||
origGetAttrSet := getAttrSetFromResourceDetector
|
||||
getAttrSetFromResourceDetector = func(context.Context) *attribute.Set {
|
||||
return &attrSet
|
||||
}
|
||||
defer func() { getAttrSetFromResourceDetector = origGetAttrSet }()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
localLabelsGot, mdEncoded := constructMetadataFromEnv(ctx)
|
||||
if diff := cmp.Diff(localLabelsGot, test.localLabelsWant); diff != "" {
|
||||
t.Fatalf("constructMetadataFromEnv() want: %v, got %v", test.localLabelsWant, localLabelsGot)
|
||||
}
|
||||
|
||||
verifyMetadataExchangeLabels(mdEncoded, test.metadataExchangeLabelsWant)
|
||||
}()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func verifyMetadataExchangeLabels(mdEncoded string, mdLabelsWant map[string]string) error {
|
||||
protoWireFormat, err := base64.RawStdEncoding.DecodeString(mdEncoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error base 64 decoding metadata val: %v", err)
|
||||
}
|
||||
spb := &structpb.Struct{}
|
||||
if err := proto.Unmarshal(protoWireFormat, spb); err != nil {
|
||||
return fmt.Errorf("error unmarshaling proto wire format: %v", err)
|
||||
}
|
||||
fields := spb.GetFields()
|
||||
for k, v := range mdLabelsWant {
|
||||
if val, ok := fields[k]; !ok {
|
||||
if _, ok := val.GetKind().(*structpb.Value_StringValue); !ok {
|
||||
return fmt.Errorf("struct value for key %v should be string type", k)
|
||||
}
|
||||
if val.GetStringValue() != v {
|
||||
return fmt.Errorf("struct value for key %v got: %v, want %v", k, val.GetStringValue(), v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(mdLabelsWant) != len(fields) {
|
||||
return fmt.Errorf("len(mdLabelsWant) = %v, len(mdLabelsGot) = %v", len(mdLabelsWant), len(fields))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
2772
stats/opentelemetry/e2e_test.go
Normal file
2772
stats/opentelemetry/e2e_test.go
Normal file
File diff suppressed because it is too large
Load Diff
151
stats/opentelemetry/example_test.go
Normal file
151
stats/opentelemetry/example_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry_test
|
||||
|
||||
import (
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/stats/opentelemetry"
|
||||
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
)
|
||||
|
||||
func ExampleDialOption_basic() {
|
||||
// This is setting default bounds for a view. Setting these bounds through
|
||||
// meter provider from SDK is recommended, as API calls in this module
|
||||
// provide default bounds, but these calls are not guaranteed to be stable
|
||||
// and API implementors are not required to implement bounds. Setting bounds
|
||||
// through SDK ensures the bounds get picked up. The specific fields in
|
||||
// Aggregation take precedence over defaults from API. For any fields unset
|
||||
// in aggregation, defaults get picked up, so can have a mix of fields from
|
||||
// SDK and fields created from API call. The overridden views themselves
|
||||
// also follow same logic, only the specific views being created in the SDK
|
||||
// use SDK information, the rest are created from API call.
|
||||
reader := metric.NewManualReader()
|
||||
provider := metric.NewMeterProvider(
|
||||
metric.WithReader(reader),
|
||||
metric.WithView(metric.NewView(metric.Instrument{
|
||||
Name: "grpc.client.call.duration",
|
||||
},
|
||||
metric.Stream{
|
||||
Aggregation: metric.AggregationExplicitBucketHistogram{
|
||||
Boundaries: opentelemetry.DefaultSizeBounds, // The specific fields set in SDK take precedence over API.
|
||||
},
|
||||
},
|
||||
)),
|
||||
)
|
||||
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
Metrics: opentelemetry.DefaultMetrics(), // equivalent to unset - distinct from empty
|
||||
},
|
||||
}
|
||||
do := opentelemetry.DialOption(opts)
|
||||
cc, err := grpc.NewClient("<target string>", do, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
// Handle err.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
func ExampleServerOption_methodFilter() {
|
||||
reader := metric.NewManualReader()
|
||||
provider := metric.NewMeterProvider(metric.WithReader(reader))
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
MeterProvider: provider,
|
||||
// Because Metrics is unset, the user will get default metrics.
|
||||
MethodAttributeFilter: func(str string) bool {
|
||||
// Will allow duplex/any other type of RPC.
|
||||
return str != "/grpc.testing.TestService/UnaryCall"
|
||||
},
|
||||
},
|
||||
}
|
||||
cc, err := grpc.NewClient("some-target", opentelemetry.DialOption(opts), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
// Handle err.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
func ExampleMetrics_excludeSome() {
|
||||
// To exclude specific metrics, initialize Options as follows:
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
Metrics: opentelemetry.DefaultMetrics().Remove(opentelemetry.ClientAttemptDurationMetricName, opentelemetry.ClientAttemptRcvdCompressedTotalMessageSizeMetricName),
|
||||
},
|
||||
}
|
||||
do := opentelemetry.DialOption(opts)
|
||||
cc, err := grpc.NewClient("<target string>", do, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
// Handle err.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
func ExampleMetrics_disableAll() {
|
||||
// To disable all metrics, initialize Options as follows:
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
Metrics: stats.NewMetricSet(), // Distinct to nil, which creates default metrics. This empty set creates no metrics.
|
||||
},
|
||||
}
|
||||
do := opentelemetry.DialOption(opts)
|
||||
cc, err := grpc.NewClient("<target string>", do, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
// Handle err.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
func ExampleMetrics_enableSome() {
|
||||
// To only create specific metrics, initialize Options as follows:
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
Metrics: stats.NewMetricSet(opentelemetry.ClientAttemptDurationMetricName, opentelemetry.ClientAttemptRcvdCompressedTotalMessageSizeMetricName), // only create these metrics
|
||||
},
|
||||
}
|
||||
do := opentelemetry.DialOption(opts)
|
||||
cc, err := grpc.NewClient("<target string>", do, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil { // might fail vet
|
||||
// Handle err.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
|
||||
func ExampleOptions_addExperimentalMetrics() {
|
||||
opts := opentelemetry.Options{
|
||||
MetricsOptions: opentelemetry.MetricsOptions{
|
||||
// These are example experimental gRPC metrics, which are disabled
|
||||
// by default and must be explicitly enabled. For the full,
|
||||
// up-to-date list of metrics, see:
|
||||
// https://grpc.io/docs/guides/opentelemetry-metrics/#instruments
|
||||
Metrics: opentelemetry.DefaultMetrics().Add(
|
||||
"grpc.subchannel.connection_attempts_succeeded",
|
||||
"grpc.subchannel.connection_attempts_failed",
|
||||
),
|
||||
},
|
||||
}
|
||||
do := opentelemetry.DialOption(opts)
|
||||
cc, err := grpc.NewClient("<target string>", do, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
// Handle error.
|
||||
}
|
||||
defer cc.Close()
|
||||
}
|
||||
119
stats/opentelemetry/grpc_trace_bin_propagator.go
Normal file
119
stats/opentelemetry/grpc_trace_bin_propagator.go
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
otelpropagation "go.opentelemetry.io/otel/propagation"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// gRPCTraceBinHeaderKey is the gRPC metadata header key `grpc-trace-bin` used
|
||||
// to propagate trace context in binary format.
|
||||
const grpcTraceBinHeaderKey = "grpc-trace-bin"
|
||||
|
||||
// GRPCTraceBinPropagator is an OpenTelemetry TextMapPropagator which is used
|
||||
// to extract and inject trace context data from and into headers exchanged by
|
||||
// gRPC applications. It propagates trace data in binary format using the
|
||||
// `grpc-trace-bin` header.
|
||||
type GRPCTraceBinPropagator struct{}
|
||||
|
||||
// Inject sets OpenTelemetry span context from the Context into the carrier as
|
||||
// a `grpc-trace-bin` header if span context is valid.
|
||||
//
|
||||
// If span context is not valid, it returns without setting `grpc-trace-bin`
|
||||
// header.
|
||||
func (GRPCTraceBinPropagator) Inject(ctx context.Context, carrier otelpropagation.TextMapCarrier) {
|
||||
sc := oteltrace.SpanFromContext(ctx)
|
||||
if !sc.SpanContext().IsValid() {
|
||||
return
|
||||
}
|
||||
|
||||
bd := toBinary(sc.SpanContext())
|
||||
carrier.Set(grpcTraceBinHeaderKey, string(bd))
|
||||
}
|
||||
|
||||
// Extract reads OpenTelemetry span context from the `grpc-trace-bin` header of
|
||||
// carrier into the provided context, if present.
|
||||
//
|
||||
// If a valid span context is retrieved from `grpc-trace-bin`, it returns a new
|
||||
// context containing the extracted OpenTelemetry span context marked as
|
||||
// remote.
|
||||
//
|
||||
// If `grpc-trace-bin` header is not present, it returns the context as is.
|
||||
func (GRPCTraceBinPropagator) Extract(ctx context.Context, carrier otelpropagation.TextMapCarrier) context.Context {
|
||||
h := carrier.Get(grpcTraceBinHeaderKey)
|
||||
if h == "" {
|
||||
return ctx
|
||||
}
|
||||
|
||||
sc, ok := fromBinary([]byte(h))
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
return oteltrace.ContextWithRemoteSpanContext(ctx, sc)
|
||||
}
|
||||
|
||||
// Fields returns the keys whose values are set with Inject.
|
||||
//
|
||||
// GRPCTraceBinPropagator always returns a slice containing only
|
||||
// `grpc-trace-bin` key because it only sets the `grpc-trace-bin` header for
|
||||
// propagating trace context.
|
||||
func (GRPCTraceBinPropagator) Fields() []string {
|
||||
return []string{grpcTraceBinHeaderKey}
|
||||
}
|
||||
|
||||
// toBinary returns the binary format representation of a SpanContext.
|
||||
//
|
||||
// If sc is the zero value, returns nil.
|
||||
func toBinary(sc oteltrace.SpanContext) []byte {
|
||||
if sc.Equal(oteltrace.SpanContext{}) {
|
||||
return nil
|
||||
}
|
||||
var b [29]byte
|
||||
traceID := oteltrace.TraceID(sc.TraceID())
|
||||
copy(b[2:18], traceID[:])
|
||||
b[18] = 1
|
||||
spanID := oteltrace.SpanID(sc.SpanID())
|
||||
copy(b[19:27], spanID[:])
|
||||
b[27] = 2
|
||||
b[28] = byte(oteltrace.TraceFlags(sc.TraceFlags()))
|
||||
return b[:]
|
||||
}
|
||||
|
||||
// fromBinary returns the SpanContext represented by b with Remote set to true.
|
||||
//
|
||||
// It returns with zero value SpanContext and false, if any of the
|
||||
// below condition is not satisfied:
|
||||
// - Valid header: len(b) = 29
|
||||
// - Valid version: b[0] = 0
|
||||
// - Valid traceID prefixed with 0: b[1] = 0
|
||||
// - Valid spanID prefixed with 1: b[18] = 1
|
||||
// - Valid traceFlags prefixed with 2: b[27] = 2
|
||||
func fromBinary(b []byte) (oteltrace.SpanContext, bool) {
|
||||
if len(b) != 29 || b[0] != 0 || b[1] != 0 || b[18] != 1 || b[27] != 2 {
|
||||
return oteltrace.SpanContext{}, false
|
||||
}
|
||||
|
||||
return oteltrace.SpanContext{}.WithTraceID(
|
||||
oteltrace.TraceID(b[2:18])).WithSpanID(
|
||||
oteltrace.SpanID(b[19:27])).WithTraceFlags(
|
||||
oteltrace.TraceFlags(b[28])).WithRemote(true), true
|
||||
}
|
||||
219
stats/opentelemetry/grpc_trace_bin_propagator_test.go
Normal file
219
stats/opentelemetry/grpc_trace_bin_propagator_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
oteltrace "go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc/metadata"
|
||||
itracing "google.golang.org/grpc/stats/opentelemetry/internal/tracing"
|
||||
)
|
||||
|
||||
var validSpanContext = oteltrace.SpanContext{}.WithTraceID(
|
||||
oteltrace.TraceID{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}).WithSpanID(
|
||||
oteltrace.SpanID{17, 18, 19, 20, 21, 22, 23, 24}).WithTraceFlags(
|
||||
oteltrace.TraceFlags(1))
|
||||
|
||||
// TestInject_ValidSpanContext verifies that the GRPCTraceBinPropagator
|
||||
// correctly injects a valid OpenTelemetry span context as `grpc-trace-bin`
|
||||
// header in the provided carrier's context metadata.
|
||||
//
|
||||
// It verifies that if a valid span context is injected, same span context can
|
||||
// can be retreived from the carrier's context metadata.
|
||||
func (s) TestInject_ValidSpanContext(t *testing.T) {
|
||||
p := GRPCTraceBinPropagator{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c := itracing.NewOutgoingCarrier(ctx)
|
||||
ctx = oteltrace.ContextWithSpanContext(ctx, validSpanContext)
|
||||
|
||||
p.Inject(ctx, c)
|
||||
|
||||
md, _ := metadata.FromOutgoingContext(c.Context())
|
||||
gotH := md.Get(grpcTraceBinHeaderKey)
|
||||
if gotH[len(gotH)-1] == "" {
|
||||
t.Fatalf("got empty value from Carrier's context metadata grpc-trace-bin header, want valid span context: %v", validSpanContext)
|
||||
}
|
||||
gotSC, ok := fromBinary([]byte(gotH[len(gotH)-1]))
|
||||
if !ok {
|
||||
t.Fatalf("got invalid span context %v from Carrier's context metadata grpc-trace-bin header, want valid span context: %v", gotSC, validSpanContext)
|
||||
}
|
||||
if cmp.Equal(validSpanContext, gotSC) {
|
||||
t.Fatalf("got span context = %v, want span contexts %v", gotSC, validSpanContext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInject_InvalidSpanContext verifies that the GRPCTraceBinPropagator does
|
||||
// not inject an invalid OpenTelemetry span context as `grpc-trace-bin` header
|
||||
// in the provided carrier's context metadata.
|
||||
//
|
||||
// If an invalid span context is injected, it verifies that `grpc-trace-bin`
|
||||
// header is not set in the carrier's context metadata.
|
||||
func (s) TestInject_InvalidSpanContext(t *testing.T) {
|
||||
p := GRPCTraceBinPropagator{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c := itracing.NewOutgoingCarrier(ctx)
|
||||
ctx = oteltrace.ContextWithSpanContext(ctx, oteltrace.SpanContext{})
|
||||
|
||||
p.Inject(ctx, c)
|
||||
|
||||
md, _ := metadata.FromOutgoingContext(c.Context())
|
||||
if gotH := md.Get(grpcTraceBinHeaderKey); len(gotH) > 0 {
|
||||
t.Fatalf("got %v value from Carrier's context metadata grpc-trace-bin header, want empty", gotH)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtract verifies that the GRPCTraceBinPropagator correctly extracts
|
||||
// OpenTelemetry span context data from the provided context using carrier.
|
||||
//
|
||||
// If a valid span context was injected, it verifies same trace span context
|
||||
// is extracted from carrier's metadata for `grpc-trace-bin` header key.
|
||||
//
|
||||
// If invalid span context was injected, it verifies that valid trace span
|
||||
// context is not extracted.
|
||||
func (s) TestExtract(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantSC oteltrace.SpanContext // expected span context from carrier
|
||||
}{
|
||||
{
|
||||
name: "valid OpenTelemetry span context",
|
||||
wantSC: validSpanContext.WithRemote(true),
|
||||
},
|
||||
{
|
||||
name: "invalid OpenTelemetry span context",
|
||||
wantSC: oteltrace.SpanContext{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
p := GRPCTraceBinPropagator{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ctx = metadata.NewIncomingContext(ctx, metadata.MD{grpcTraceBinHeaderKey: []string{string(toBinary(test.wantSC))}})
|
||||
|
||||
c := itracing.NewIncomingCarrier(ctx)
|
||||
|
||||
tCtx := p.Extract(ctx, c)
|
||||
got := oteltrace.SpanContextFromContext(tCtx)
|
||||
if !got.Equal(test.wantSC) {
|
||||
t.Fatalf("got span context: %v, want span context: %v", got, test.wantSC)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBinary verifies that the toBinary() function correctly serializes a valid
|
||||
// OpenTelemetry span context into its binary format representation. If span
|
||||
// context is invalid, it verifies that serialization is nil.
|
||||
func (s) TestToBinary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sc oteltrace.SpanContext
|
||||
want []byte
|
||||
}{
|
||||
{
|
||||
name: "valid context",
|
||||
sc: validSpanContext,
|
||||
want: toBinary(validSpanContext),
|
||||
},
|
||||
{
|
||||
name: "zero value context",
|
||||
sc: oteltrace.SpanContext{},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := toBinary(test.sc); !cmp.Equal(got, test.want) {
|
||||
t.Fatalf("binary() = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromBinary verifies that the fromBinary() function correctly
|
||||
// deserializes a binary format representation of a valid OpenTelemetry span
|
||||
// context into its corresponding span context format. If span context's binary
|
||||
// representation is invalid, it verifies that deserialization is zero value
|
||||
// span context.
|
||||
func (s) TestFromBinary(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
b []byte
|
||||
want oteltrace.SpanContext
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
b: []byte{0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 17, 18, 19, 20, 21, 22, 23, 24, 2, 1},
|
||||
want: validSpanContext.WithRemote(true),
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "invalid length",
|
||||
b: []byte{0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 17, 18, 19, 20, 21, 22, 23, 24, 2},
|
||||
want: oteltrace.SpanContext{},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "invalid version",
|
||||
b: []byte{1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 17, 18, 19, 20, 21, 22, 23, 24, 2, 1},
|
||||
want: oteltrace.SpanContext{},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "invalid traceID field ID",
|
||||
b: []byte{0, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 17, 18, 19, 20, 21, 22, 23, 24, 2, 1},
|
||||
want: oteltrace.SpanContext{},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "invalid spanID field ID",
|
||||
b: []byte{0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 0, 17, 18, 19, 20, 21, 22, 23, 24, 2, 1},
|
||||
want: oteltrace.SpanContext{},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "invalid traceFlags field ID",
|
||||
b: []byte{0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 17, 18, 19, 20, 21, 22, 23, 24, 1, 1},
|
||||
want: oteltrace.SpanContext{},
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, ok := fromBinary(test.b)
|
||||
if ok != test.ok {
|
||||
t.Fatalf("fromBinary() ok = %v, want %v", ok, test.ok)
|
||||
return
|
||||
}
|
||||
if !got.Equal(test.want) {
|
||||
t.Fatalf("fromBinary() got = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
41
stats/opentelemetry/internal/pluginoption.go
Normal file
41
stats/opentelemetry/internal/pluginoption.go
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Package internal defines the PluginOption interface.
|
||||
package internal
|
||||
|
||||
import (
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// SetPluginOption sets the plugin option on Options.
|
||||
var SetPluginOption any // func(*Options, PluginOption)
|
||||
|
||||
// PluginOption is the interface which represents a plugin option for the
|
||||
// OpenTelemetry instrumentation component. This plugin option emits labels from
|
||||
// metadata and also creates metadata containing labels. These labels are
|
||||
// intended to be added to applicable OpenTelemetry metrics recorded in the
|
||||
// OpenTelemetry instrumentation component.
|
||||
//
|
||||
// In the future, we hope to stabilize and expose this API to allow plugins to
|
||||
// inject labels of their choosing into metrics recorded.
|
||||
type PluginOption interface {
|
||||
// GetMetadata creates a MD with metadata exchange labels.
|
||||
GetMetadata() metadata.MD
|
||||
// GetLabels emits labels to be attached to metrics for the RPC that
|
||||
// contains the provided incomingMetadata.
|
||||
GetLabels(incomingMetadata metadata.MD) map[string]string
|
||||
}
|
||||
853
stats/opentelemetry/internal/testutils/testutils.go
Normal file
853
stats/opentelemetry/internal/testutils/testutils.go
Normal file
@@ -0,0 +1,853 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Package testutils contains helpers for OpenTelemetry tests.
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// Redefine default bounds here to avoid a cyclic dependency with top level
|
||||
// opentelemetry package. Could define once through internal, but would make
|
||||
// external opentelemetry godoc less readable.
|
||||
var (
|
||||
// DefaultLatencyBounds are the default bounds for latency metrics.
|
||||
DefaultLatencyBounds = []float64{0, 0.00001, 0.00005, 0.0001, 0.0003, 0.0006, 0.0008, 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.01, 0.013, 0.016, 0.02, 0.025, 0.03, 0.04, 0.05, 0.065, 0.08, 0.1, 0.13, 0.16, 0.2, 0.25, 0.3, 0.4, 0.5, 0.65, 0.8, 1, 2, 5, 10, 20, 50, 100}
|
||||
// DefaultSizeBounds are the default bounds for metrics which record size.
|
||||
DefaultSizeBounds = []float64{0, 1024, 2048, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
|
||||
)
|
||||
|
||||
// waitForServerCompletedRPCs waits until the unary and streaming stats.End
|
||||
// calls are finished processing. It does this by waiting for the expected
|
||||
// metric triggered by stats.End to appear through the passed in metrics reader.
|
||||
//
|
||||
// Returns a new gotMetrics map containing the metric data being polled for, or
|
||||
// an error if failed to wait for metric.
|
||||
func waitForServerCompletedRPCs(ctx context.Context, reader metric.Reader, wantMetric metricdata.Metrics) (map[string]metricdata.Metrics, error) {
|
||||
for ; ctx.Err() == nil; <-time.After(time.Millisecond) {
|
||||
rm := &metricdata.ResourceMetrics{}
|
||||
reader.Collect(ctx, rm)
|
||||
gotMetrics := map[string]metricdata.Metrics{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
gotMetrics[m.Name] = m
|
||||
}
|
||||
}
|
||||
val, ok := gotMetrics[wantMetric.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch data := val.Data.(type) {
|
||||
case metricdata.Histogram[int64]:
|
||||
if len(wantMetric.Data.(metricdata.Histogram[int64]).DataPoints) > len(data.DataPoints) {
|
||||
continue
|
||||
}
|
||||
case metricdata.Histogram[float64]:
|
||||
if len(wantMetric.Data.(metricdata.Histogram[float64]).DataPoints) > len(data.DataPoints) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return gotMetrics, nil
|
||||
}
|
||||
return nil, fmt.Errorf("error waiting for metric %v: %v", wantMetric, ctx.Err())
|
||||
}
|
||||
|
||||
// checkDataPointWithinFiveSeconds checks if the metric passed in contains a
|
||||
// histogram with dataPoints that fall within buckets that are <=5. Returns an
|
||||
// error if check fails.
|
||||
func checkDataPointWithinFiveSeconds(metric metricdata.Metrics) error {
|
||||
histo, ok := metric.Data.(metricdata.Histogram[float64])
|
||||
if !ok {
|
||||
return fmt.Errorf("metric data is not histogram")
|
||||
}
|
||||
for _, dataPoint := range histo.DataPoints {
|
||||
var boundWithFive int
|
||||
for i, bound := range dataPoint.Bounds {
|
||||
if bound >= 5 {
|
||||
boundWithFive = i
|
||||
}
|
||||
}
|
||||
foundPoint := false
|
||||
for i, count := range dataPoint.BucketCounts {
|
||||
if i >= boundWithFive {
|
||||
return fmt.Errorf("data point not found in bucket <=5 seconds")
|
||||
}
|
||||
if count == 1 {
|
||||
foundPoint = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundPoint {
|
||||
return fmt.Errorf("no data point found for metric")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MetricDataOptions are the options used to configure the metricData emissions
|
||||
// of expected metrics data from NewMetricData.
|
||||
type MetricDataOptions struct {
|
||||
// CSMLabels are the csm labels to attach to metrics which receive csm
|
||||
// labels (all A66 expect client call and started RPC's client and server
|
||||
// side).
|
||||
CSMLabels []attribute.KeyValue
|
||||
// Target is the target of the client and server.
|
||||
Target string
|
||||
// UnaryCallFailed is whether the Unary Call failed, which would trigger
|
||||
// trailers only.
|
||||
UnaryCallFailed bool
|
||||
// UnaryCompressedMessageSize is the compressed message size of the Unary
|
||||
// RPC. This assumes both client and server sent the same message size.
|
||||
UnaryCompressedMessageSize float64
|
||||
// StreamingCompressedMessageSize is the compressed message size of the
|
||||
// Streaming RPC. This assumes both client and server sent the same message
|
||||
// size.
|
||||
StreamingCompressedMessageSize float64
|
||||
}
|
||||
|
||||
// createBucketCounts creates a list of bucket counts based off the
|
||||
// recordingPoints and bounds. Both recordingPoints and bounds are assumed to be
|
||||
// in order.
|
||||
func createBucketCounts(recordingPoints []float64, bounds []float64) []uint64 {
|
||||
var bucketCounts []uint64
|
||||
var recordingPointIndex int
|
||||
for _, bound := range bounds {
|
||||
var bucketCount uint64
|
||||
if recordingPointIndex >= len(recordingPoints) {
|
||||
bucketCounts = append(bucketCounts, bucketCount)
|
||||
continue
|
||||
}
|
||||
for recordingPoints[recordingPointIndex] <= bound {
|
||||
bucketCount++
|
||||
recordingPointIndex++
|
||||
if recordingPointIndex >= len(recordingPoints) {
|
||||
break
|
||||
}
|
||||
}
|
||||
bucketCounts = append(bucketCounts, bucketCount)
|
||||
}
|
||||
// The rest of the recording points are last bound -> infinity.
|
||||
bucketCounts = append(bucketCounts, uint64(len(recordingPoints)-recordingPointIndex))
|
||||
return bucketCounts
|
||||
}
|
||||
|
||||
// MetricDataUnary returns a list of expected metrics defined in A66 for a
|
||||
// client and server for one unary RPC.
|
||||
func MetricDataUnary(options MetricDataOptions) []metricdata.Metrics {
|
||||
methodAttr := attribute.String("grpc.method", "grpc.testing.TestService/UnaryCall")
|
||||
targetAttr := attribute.String("grpc.target", options.Target)
|
||||
statusAttr := attribute.String("grpc.status", "OK")
|
||||
if options.UnaryCallFailed {
|
||||
statusAttr = attribute.String("grpc.status", "UNKNOWN")
|
||||
}
|
||||
clientSideEnd := []attribute.KeyValue{
|
||||
methodAttr,
|
||||
targetAttr,
|
||||
statusAttr,
|
||||
}
|
||||
serverSideEnd := []attribute.KeyValue{
|
||||
methodAttr,
|
||||
statusAttr,
|
||||
}
|
||||
clientSideEnd = append(clientSideEnd, options.CSMLabels...)
|
||||
serverSideEnd = append(serverSideEnd, options.CSMLabels...)
|
||||
compressedBytesSentRecv := int64(options.UnaryCompressedMessageSize)
|
||||
bucketCounts := createBucketCounts([]float64{options.UnaryCompressedMessageSize}, DefaultSizeBounds)
|
||||
extrema := metricdata.NewExtrema(int64(options.UnaryCompressedMessageSize))
|
||||
return []metricdata.Metrics{
|
||||
{
|
||||
Name: "grpc.client.attempt.started",
|
||||
Description: "Number of client call attempts started.",
|
||||
Unit: "{attempt}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr, targetAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.duration",
|
||||
Description: "End-to-end time taken to complete a client call attempt.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.sent_total_compressed_message_size",
|
||||
Description: "Compressed message bytes sent per client call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.rcvd_total_compressed_message_size",
|
||||
Description: "Compressed message bytes received per call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.call.duration",
|
||||
Description: "Time taken by gRPC to complete an RPC from application's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr, targetAttr, statusAttr),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.started",
|
||||
Description: "Number of server calls started.",
|
||||
Unit: "{call}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.sent_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes sent per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.rcvd_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes received per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.duration",
|
||||
Description: "End-to-end time taken to complete a call from server transport's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MetricDataStreaming returns a list of expected metrics defined in A66 for a
|
||||
// client and server for one streaming RPC.
|
||||
func MetricDataStreaming(options MetricDataOptions) []metricdata.Metrics {
|
||||
methodAttr := attribute.String("grpc.method", "grpc.testing.TestService/FullDuplexCall")
|
||||
targetAttr := attribute.String("grpc.target", options.Target)
|
||||
statusAttr := attribute.String("grpc.status", "OK")
|
||||
clientSideEnd := []attribute.KeyValue{
|
||||
methodAttr,
|
||||
targetAttr,
|
||||
statusAttr,
|
||||
}
|
||||
serverSideEnd := []attribute.KeyValue{
|
||||
methodAttr,
|
||||
statusAttr,
|
||||
}
|
||||
clientSideEnd = append(clientSideEnd, options.CSMLabels...)
|
||||
serverSideEnd = append(serverSideEnd, options.CSMLabels...)
|
||||
compressedBytesSentRecv := int64(options.StreamingCompressedMessageSize)
|
||||
bucketCounts := createBucketCounts([]float64{options.StreamingCompressedMessageSize}, DefaultSizeBounds)
|
||||
extrema := metricdata.NewExtrema(int64(options.StreamingCompressedMessageSize))
|
||||
return []metricdata.Metrics{
|
||||
{
|
||||
Name: "grpc.client.attempt.started",
|
||||
Description: "Number of client call attempts started.",
|
||||
Unit: "{attempt}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr, targetAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.duration",
|
||||
Description: "End-to-end time taken to complete a client call attempt.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.sent_total_compressed_message_size",
|
||||
Description: "Compressed message bytes sent per client call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.rcvd_total_compressed_message_size",
|
||||
Description: "Compressed message bytes received per call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(clientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.call.duration",
|
||||
Description: "Time taken by gRPC to complete an RPC from application's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr, targetAttr, statusAttr),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.started",
|
||||
Description: "Number of server calls started.",
|
||||
Unit: "{call}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(methodAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.sent_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes sent per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.rcvd_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes received per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: bucketCounts,
|
||||
Min: extrema,
|
||||
Max: extrema,
|
||||
Sum: compressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.duration",
|
||||
Description: "End-to-end time taken to complete a call from server transport's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(serverSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MetricData returns a metricsDataSlice for A66 metrics for client and server
|
||||
// with a unary RPC and streaming RPC with certain compression and message flow
|
||||
// sent. If csmAttributes is set to true, the corresponding CSM Metrics (not
|
||||
// client side call metrics, or started on client and server side).
|
||||
func MetricData(options MetricDataOptions) []metricdata.Metrics {
|
||||
unaryMethodAttr := attribute.String("grpc.method", "grpc.testing.TestService/UnaryCall")
|
||||
duplexMethodAttr := attribute.String("grpc.method", "grpc.testing.TestService/FullDuplexCall")
|
||||
targetAttr := attribute.String("grpc.target", options.Target)
|
||||
unaryStatusAttr := attribute.String("grpc.status", "OK")
|
||||
streamingStatusAttr := attribute.String("grpc.status", "OK")
|
||||
if options.UnaryCallFailed {
|
||||
unaryStatusAttr = attribute.String("grpc.status", "UNKNOWN")
|
||||
}
|
||||
unaryMethodClientSideEnd := []attribute.KeyValue{
|
||||
unaryMethodAttr,
|
||||
targetAttr,
|
||||
unaryStatusAttr,
|
||||
}
|
||||
streamingMethodClientSideEnd := []attribute.KeyValue{
|
||||
duplexMethodAttr,
|
||||
targetAttr,
|
||||
streamingStatusAttr,
|
||||
}
|
||||
unaryMethodServerSideEnd := []attribute.KeyValue{
|
||||
unaryMethodAttr,
|
||||
unaryStatusAttr,
|
||||
}
|
||||
streamingMethodServerSideEnd := []attribute.KeyValue{
|
||||
duplexMethodAttr,
|
||||
streamingStatusAttr,
|
||||
}
|
||||
|
||||
unaryMethodClientSideEnd = append(unaryMethodClientSideEnd, options.CSMLabels...)
|
||||
streamingMethodClientSideEnd = append(streamingMethodClientSideEnd, options.CSMLabels...)
|
||||
unaryMethodServerSideEnd = append(unaryMethodServerSideEnd, options.CSMLabels...)
|
||||
streamingMethodServerSideEnd = append(streamingMethodServerSideEnd, options.CSMLabels...)
|
||||
unaryCompressedBytesSentRecv := int64(options.UnaryCompressedMessageSize)
|
||||
unaryBucketCounts := createBucketCounts([]float64{options.UnaryCompressedMessageSize}, DefaultSizeBounds)
|
||||
unaryExtrema := metricdata.NewExtrema(int64(options.UnaryCompressedMessageSize))
|
||||
|
||||
streamingCompressedBytesSentRecv := int64(options.StreamingCompressedMessageSize)
|
||||
streamingBucketCounts := createBucketCounts([]float64{options.StreamingCompressedMessageSize}, DefaultSizeBounds)
|
||||
streamingExtrema := metricdata.NewExtrema(int64(options.StreamingCompressedMessageSize))
|
||||
|
||||
return []metricdata.Metrics{
|
||||
{
|
||||
Name: "grpc.client.attempt.started",
|
||||
Description: "Number of client call attempts started.",
|
||||
Unit: "{attempt}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodAttr, targetAttr),
|
||||
Value: 1,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(duplexMethodAttr, targetAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.duration",
|
||||
Description: "End-to-end time taken to complete a client call attempt.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.sent_total_compressed_message_size",
|
||||
Description: "Compressed message bytes sent per client call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: streamingBucketCounts,
|
||||
Min: streamingExtrema,
|
||||
Max: streamingExtrema,
|
||||
Sum: streamingCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.attempt.rcvd_total_compressed_message_size",
|
||||
Description: "Compressed message bytes received per call attempt.",
|
||||
Unit: "By",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodClientSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: streamingBucketCounts,
|
||||
Min: streamingExtrema,
|
||||
Max: streamingExtrema,
|
||||
Sum: streamingCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.client.call.duration",
|
||||
Description: "Time taken by gRPC to complete an RPC from application's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodAttr, targetAttr, unaryStatusAttr),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(duplexMethodAttr, targetAttr, streamingStatusAttr),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.started",
|
||||
Description: "Number of server calls started.",
|
||||
Unit: "{call}",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodAttr),
|
||||
Value: 1,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(duplexMethodAttr),
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.sent_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes sent per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: streamingBucketCounts,
|
||||
Min: streamingExtrema,
|
||||
Max: streamingExtrema,
|
||||
Sum: streamingCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.rcvd_total_compressed_message_size",
|
||||
Unit: "By",
|
||||
Description: "Compressed message bytes received per server call.",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: unaryBucketCounts,
|
||||
Min: unaryExtrema,
|
||||
Max: unaryExtrema,
|
||||
Sum: unaryCompressedBytesSentRecv,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultSizeBounds,
|
||||
BucketCounts: streamingBucketCounts,
|
||||
Min: streamingExtrema,
|
||||
Max: streamingExtrema,
|
||||
Sum: streamingCompressedBytesSentRecv,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grpc.server.call.duration",
|
||||
Description: "End-to-end time taken to complete a call from server transport's perspective.",
|
||||
Unit: "s",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(unaryMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
{
|
||||
Attributes: attribute.NewSet(streamingMethodServerSideEnd...),
|
||||
Count: 1,
|
||||
Bounds: DefaultLatencyBounds,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GzipCompressedMessageSize returns the byte length of the proto message after
|
||||
// gzip compression.
|
||||
func GzipCompressedMessageSize(t *testing.T, m proto.Message) int {
|
||||
t.Helper()
|
||||
data, err := proto.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatalf("proto.Marshal failed: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write(data); err != nil {
|
||||
t.Fatalf("gzip.Write failed: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("gzip.Close failed: %v", err)
|
||||
}
|
||||
return buf.Len()
|
||||
}
|
||||
|
||||
// CompareMetrics asserts wantMetrics are what we expect. For duration metrics
|
||||
// makes sure the data point is within possible testing time (five seconds from
|
||||
// context timeout).
|
||||
func CompareMetrics(t *testing.T, gotMetrics map[string]metricdata.Metrics, wantMetrics []metricdata.Metrics) {
|
||||
for _, metric := range wantMetrics {
|
||||
val, ok := gotMetrics[metric.Name]
|
||||
if !ok {
|
||||
t.Errorf("Metric %v not present in recorded metrics", metric.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
if metric.Name == "grpc.server.call.sent_total_compressed_message_size" || metric.Name == "grpc.server.call.rcvd_total_compressed_message_size" {
|
||||
val := gotMetrics[metric.Name]
|
||||
if !metricdatatest.AssertEqual(t, metric, val, metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreExemplars()) {
|
||||
t.Errorf("Metrics data type not equal for metric: %v", metric.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If one of the duration metrics, ignore the bucket counts, and make
|
||||
// sure it count falls within a bucket <= 5 seconds (maximum duration of
|
||||
// test due to context).
|
||||
if metric.Name == "grpc.client.attempt.duration" || metric.Name == "grpc.client.call.duration" || metric.Name == "grpc.server.call.duration" {
|
||||
val := gotMetrics[metric.Name]
|
||||
if !metricdatatest.AssertEqual(t, metric, val, metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreExemplars(), metricdatatest.IgnoreValue()) {
|
||||
t.Errorf("Metrics data type not equal for metric: %v", metric.Name)
|
||||
}
|
||||
if err := checkDataPointWithinFiveSeconds(val); err != nil {
|
||||
t.Errorf("Data point not within five seconds for metric %v: %v", metric.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !metricdatatest.AssertEqual(t, metric, val, metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreExemplars()) {
|
||||
t.Errorf("Metrics data type not equal for metric: %v", metric.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForServerMetrics waits for eventual server metrics (not emitted
|
||||
// synchronously with client side rpc returning).
|
||||
func WaitForServerMetrics(ctx context.Context, t *testing.T, mr *metric.ManualReader, gotMetrics map[string]metricdata.Metrics, wantMetrics []metricdata.Metrics) map[string]metricdata.Metrics {
|
||||
terminalMetrics := []string{
|
||||
"grpc.server.call.sent_total_compressed_message_size",
|
||||
"grpc.server.call.rcvd_total_compressed_message_size",
|
||||
"grpc.client.attempt.duration",
|
||||
"grpc.client.call.duration",
|
||||
"grpc.server.call.duration",
|
||||
}
|
||||
for _, metric := range wantMetrics {
|
||||
if !slices.Contains(terminalMetrics, metric.Name) {
|
||||
continue
|
||||
}
|
||||
// Sync the metric reader to see the event because stats.End is
|
||||
// handled async server side. Thus, poll until metrics created from
|
||||
// stats.End show up.
|
||||
var err error
|
||||
if gotMetrics, err = waitForServerCompletedRPCs(ctx, mr, metric); err != nil { // move to shared helper
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
return gotMetrics
|
||||
}
|
||||
131
stats/opentelemetry/internal/tracing/carrier.go
Normal file
131
stats/opentelemetry/internal/tracing/carrier.go
Normal file
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
// Package tracing implements the OpenTelemetry carrier for context propagation
|
||||
// in gRPC tracing.
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
var logger = grpclog.Component("otel-plugin")
|
||||
|
||||
// IncomingCarrier is a TextMapCarrier that uses incoming `context.Context` to
|
||||
// retrieve any propagated key-value pairs in text format.
|
||||
type IncomingCarrier struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewIncomingCarrier creates a new `IncomingCarrier` with the given context.
|
||||
// The incoming carrier should be used with propagator's `Extract()` method in
|
||||
// the incoming rpc path.
|
||||
func NewIncomingCarrier(ctx context.Context) *IncomingCarrier {
|
||||
return &IncomingCarrier{ctx: ctx}
|
||||
}
|
||||
|
||||
// Get returns the string value associated with the passed key from the
|
||||
// carrier's incoming context metadata.
|
||||
//
|
||||
// It returns an empty string if the key is not present in the carrier's
|
||||
// context or if the value associated with the key is empty.
|
||||
//
|
||||
// If multiple values are present for a key, it returns the last one.
|
||||
func (c *IncomingCarrier) Get(key string) string {
|
||||
values := metadata.ValueFromIncomingContext(c.ctx, key)
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[len(values)-1]
|
||||
}
|
||||
|
||||
// Set just logs an error. It implements the `TextMapCarrier` interface but
|
||||
// should not be used with `IncomingCarrier`.
|
||||
func (c *IncomingCarrier) Set(string, string) {
|
||||
logger.Error("Set() should not be used with IncomingCarrier.")
|
||||
}
|
||||
|
||||
// Keys returns the keys stored in the carrier's context metadata. It returns
|
||||
// keys from incoming context metadata.
|
||||
func (c *IncomingCarrier) Keys() []string {
|
||||
md, ok := metadata.FromIncomingContext(c.ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(md))
|
||||
for key := range md {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Context returns the underlying context associated with the
|
||||
// `IncomingCarrier“.
|
||||
func (c *IncomingCarrier) Context() context.Context {
|
||||
return c.ctx
|
||||
}
|
||||
|
||||
// OutgoingCarrier is a TextMapCarrier that uses outgoing `context.Context` to
|
||||
// store any propagated key-value pairs in text format.
|
||||
type OutgoingCarrier struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewOutgoingCarrier creates a new Carrier with the given context. The
|
||||
// outgoing carrier should be used with propagator's `Inject()` method in the
|
||||
// outgoing rpc path.
|
||||
func NewOutgoingCarrier(ctx context.Context) *OutgoingCarrier {
|
||||
return &OutgoingCarrier{ctx: ctx}
|
||||
}
|
||||
|
||||
// Get just logs an error and returns an empty string. It implements the
|
||||
// `TextMapCarrier` interface but should not be used with `OutgoingCarrier`.
|
||||
func (c *OutgoingCarrier) Get(string) string {
|
||||
logger.Error("Get() should not be used with `OutgoingCarrier`")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Set stores the key-value pair in the carrier's outgoing context metadata.
|
||||
//
|
||||
// If the key already exists, given value is appended to the last.
|
||||
func (c *OutgoingCarrier) Set(key, value string) {
|
||||
c.ctx = metadata.AppendToOutgoingContext(c.ctx, key, value)
|
||||
}
|
||||
|
||||
// Keys returns the keys stored in the carrier's context metadata. It returns
|
||||
// keys from outgoing context metadata.
|
||||
func (c *OutgoingCarrier) Keys() []string {
|
||||
md, ok := metadata.FromOutgoingContext(c.ctx)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(md))
|
||||
for key := range md {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// Context returns the underlying context associated with the
|
||||
// `OutgoingCarrier“.
|
||||
func (c *OutgoingCarrier) Context() context.Context {
|
||||
return c.ctx
|
||||
}
|
||||
190
stats/opentelemetry/internal/tracing/carrier_test.go
Normal file
190
stats/opentelemetry/internal/tracing/carrier_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* htestp://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"google.golang.org/grpc/internal/grpctest"
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
type s struct {
|
||||
grpctest.Tester
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
grpctest.RunSubTests(t, s{})
|
||||
}
|
||||
|
||||
// TestIncomingCarrier verifies that `IncomingCarrier.Get()` returns correct
|
||||
// value for the corresponding key in the carrier's context metadata, if key is
|
||||
// present. If key is not present, it verifies that empty string is returned.
|
||||
//
|
||||
// If multiple values are present for a key, it verifies that last value is
|
||||
// returned.
|
||||
//
|
||||
// If key ends with `-bin`, it verifies that a correct binary value is returned
|
||||
// in the string format for the binary header.
|
||||
func (s) TestIncomingCarrier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
md metadata.MD
|
||||
key string
|
||||
want string
|
||||
wantKeys []string
|
||||
}{
|
||||
{
|
||||
name: "existing key",
|
||||
md: metadata.Pairs("key1", "value1"),
|
||||
key: "key1",
|
||||
want: "value1",
|
||||
wantKeys: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "non-existing key",
|
||||
md: metadata.Pairs("key1", "value1"),
|
||||
key: "key2",
|
||||
want: "",
|
||||
wantKeys: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "empty key",
|
||||
md: metadata.MD{},
|
||||
key: "key1",
|
||||
want: "",
|
||||
wantKeys: []string{},
|
||||
},
|
||||
{
|
||||
name: "more than one key/value pair",
|
||||
md: metadata.MD{"key1": []string{"value1"}, "key2": []string{"value2"}},
|
||||
key: "key2",
|
||||
want: "value2",
|
||||
wantKeys: []string{"key1", "key2"},
|
||||
},
|
||||
{
|
||||
name: "more than one value for a key",
|
||||
md: metadata.MD{"key1": []string{"value1", "value2"}},
|
||||
key: "key1",
|
||||
want: "value2",
|
||||
wantKeys: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "grpc-trace-bin key",
|
||||
md: metadata.Pairs("grpc-trace-bin", string([]byte{0x01, 0x02, 0x03})),
|
||||
key: "grpc-trace-bin",
|
||||
want: string([]byte{0x01, 0x02, 0x03}),
|
||||
wantKeys: []string{"grpc-trace-bin"},
|
||||
},
|
||||
{
|
||||
name: "grpc-trace-bin key with another string key",
|
||||
md: metadata.MD{"key1": []string{"value1"}, "grpc-trace-bin": []string{string([]byte{0x01, 0x02, 0x03})}},
|
||||
key: "grpc-trace-bin",
|
||||
want: string([]byte{0x01, 0x02, 0x03}),
|
||||
wantKeys: []string{"key1", "grpc-trace-bin"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c := NewIncomingCarrier(metadata.NewIncomingContext(ctx, test.md))
|
||||
got := c.Get(test.key)
|
||||
if got != test.want {
|
||||
t.Fatalf("c.Get() = %s, want %s", got, test.want)
|
||||
}
|
||||
if gotKeys := c.Keys(); !cmp.Equal(test.wantKeys, gotKeys, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
|
||||
t.Fatalf("c.Keys() = keys %v, want %v", gotKeys, test.wantKeys)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutgoingCarrier verifies that a key-value pair is set in carrier's
|
||||
// context metadata using `OutgoingCarrier.Set()`. If key is not present, it
|
||||
// verifies that key-value pair is insterted. If key is already present, it
|
||||
// verifies that new value is appended at the end of list for the existing key.
|
||||
//
|
||||
// If key ends with `-bin`, it verifies that a binary value is set for
|
||||
// `-bin` header in string format.
|
||||
//
|
||||
// It also verifies that both existing and newly inserted keys are present in
|
||||
// the carrier's context using `Carrier.Keys()`.
|
||||
func (s) TestOutgoingCarrier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initialMD metadata.MD
|
||||
setKey string
|
||||
setValue string
|
||||
wantValue string // expected value of the set key
|
||||
wantKeys []string
|
||||
}{
|
||||
{
|
||||
name: "new key",
|
||||
initialMD: metadata.MD{},
|
||||
setKey: "key1",
|
||||
setValue: "value1",
|
||||
wantValue: "value1",
|
||||
wantKeys: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "add to existing key",
|
||||
initialMD: metadata.MD{"key1": []string{"oldvalue"}},
|
||||
setKey: "key1",
|
||||
setValue: "newvalue",
|
||||
wantValue: "newvalue",
|
||||
wantKeys: []string{"key1"},
|
||||
},
|
||||
{
|
||||
name: "new key with different existing key",
|
||||
initialMD: metadata.MD{"key2": []string{"value2"}},
|
||||
setKey: "key1",
|
||||
setValue: "value1",
|
||||
wantValue: "value1",
|
||||
wantKeys: []string{"key2", "key1"},
|
||||
},
|
||||
{
|
||||
name: "grpc-trace-bin binary key",
|
||||
initialMD: metadata.MD{"key1": []string{"value1"}},
|
||||
setKey: "grpc-trace-bin",
|
||||
setValue: string([]byte{0x01, 0x02, 0x03}),
|
||||
wantValue: string([]byte{0x01, 0x02, 0x03}),
|
||||
wantKeys: []string{"key1", "grpc-trace-bin"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c := NewOutgoingCarrier(metadata.NewOutgoingContext(ctx, test.initialMD))
|
||||
c.Set(test.setKey, test.setValue)
|
||||
if gotKeys := c.Keys(); !cmp.Equal(test.wantKeys, gotKeys, cmpopts.SortSlices(func(a, b string) bool { return a < b })) {
|
||||
t.Fatalf("c.Keys() = keys %v, want %v", gotKeys, test.wantKeys)
|
||||
}
|
||||
if md, ok := metadata.FromOutgoingContext(c.Context()); ok && md.Get(test.setKey)[len(md.Get(test.setKey))-1] != test.wantValue {
|
||||
t.Fatalf("got value %s, want %s, for key %s", md.Get(test.setKey)[len(md.Get(test.setKey))-1], test.wantValue, test.setKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
341
stats/opentelemetry/metricsregistry_test.go
Normal file
341
stats/opentelemetry/metricsregistry_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
estats "google.golang.org/grpc/experimental/stats"
|
||||
"google.golang.org/grpc/internal"
|
||||
"google.golang.org/grpc/internal/grpctest"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
otelmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
|
||||
)
|
||||
|
||||
var defaultTestTimeout = 5 * time.Second
|
||||
|
||||
type s struct {
|
||||
grpctest.Tester
|
||||
}
|
||||
|
||||
func Test(t *testing.T) {
|
||||
grpctest.RunSubTests(t, s{})
|
||||
}
|
||||
|
||||
type metricsRecorderForTest interface {
|
||||
estats.MetricsRecorder
|
||||
initializeMetrics()
|
||||
}
|
||||
|
||||
func newClientStatsHandler(options MetricsOptions) metricsRecorderForTest {
|
||||
return &clientMetricsHandler{options: Options{MetricsOptions: options}}
|
||||
}
|
||||
|
||||
func newServerStatsHandler(options MetricsOptions) metricsRecorderForTest {
|
||||
return &serverMetricsHandler{options: Options{MetricsOptions: options}}
|
||||
}
|
||||
|
||||
// TestMetricsRegistryMetrics tests the OpenTelemetry behavior with respect to
|
||||
// registered metrics. It registers metrics in the metrics registry. It then
|
||||
// creates an OpenTelemetry client and server stats handler This test then makes
|
||||
// measurements on those instruments using one of the stats handlers, then tests
|
||||
// the expected metrics emissions, which includes default metrics and optional
|
||||
// label assertions.
|
||||
func (s) TestMetricsRegistryMetrics(t *testing.T) {
|
||||
cleanup := internal.SnapshotMetricRegistryForTesting()
|
||||
defer cleanup()
|
||||
|
||||
intCountHandle1 := estats.RegisterInt64Count(estats.MetricDescriptor{
|
||||
Name: "int-counter-1",
|
||||
Description: "Sum of calls from test",
|
||||
Unit: "int",
|
||||
Labels: []string{"int counter 1 label key"},
|
||||
OptionalLabels: []string{"int counter 1 optional label key"},
|
||||
Default: true,
|
||||
})
|
||||
// A non default metric. If not specified in OpenTelemetry constructor, this
|
||||
// will become a no-op, so measurements recorded on it won't show up in
|
||||
// emitted metrics.
|
||||
intCountHandle2 := estats.RegisterInt64Count(estats.MetricDescriptor{
|
||||
Name: "int-counter-2",
|
||||
Description: "Sum of calls from test",
|
||||
Unit: "int",
|
||||
Labels: []string{"int counter 2 label key"},
|
||||
OptionalLabels: []string{"int counter 2 optional label key"},
|
||||
Default: false,
|
||||
})
|
||||
// Register another non default metric. This will get added to the default
|
||||
// metrics set in the OpenTelemetry constructor options, so metrics recorded
|
||||
// on this should show up in metrics emissions.
|
||||
intCountHandle3 := estats.RegisterInt64Count(estats.MetricDescriptor{
|
||||
Name: "int-counter-3",
|
||||
Description: "sum of calls from test",
|
||||
Unit: "int",
|
||||
Labels: []string{"int counter 3 label key"},
|
||||
OptionalLabels: []string{"int counter 3 optional label key"},
|
||||
Default: false,
|
||||
})
|
||||
floatCountHandle := estats.RegisterFloat64Count(estats.MetricDescriptor{
|
||||
Name: "float-counter",
|
||||
Description: "sum of calls from test",
|
||||
Unit: "float",
|
||||
Labels: []string{"float counter label key"},
|
||||
OptionalLabels: []string{"float counter optional label key"},
|
||||
Default: true,
|
||||
})
|
||||
bounds := []float64{0, 5, 10}
|
||||
intHistoHandle := estats.RegisterInt64Histo(estats.MetricDescriptor{
|
||||
Name: "int-histo",
|
||||
Description: "histogram of call values from tests",
|
||||
Unit: "int",
|
||||
Labels: []string{"int histo label key"},
|
||||
OptionalLabels: []string{"int histo optional label key"},
|
||||
Default: true,
|
||||
Bounds: bounds,
|
||||
})
|
||||
floatHistoHandle := estats.RegisterFloat64Histo(estats.MetricDescriptor{
|
||||
Name: "float-histo",
|
||||
Description: "histogram of call values from tests",
|
||||
Unit: "float",
|
||||
Labels: []string{"float histo label key"},
|
||||
OptionalLabels: []string{"float histo optional label key"},
|
||||
Default: true,
|
||||
Bounds: bounds,
|
||||
})
|
||||
intGaugeHandle := estats.RegisterInt64Gauge(estats.MetricDescriptor{
|
||||
Name: "simple-gauge",
|
||||
Description: "the most recent int emitted by test",
|
||||
Unit: "int",
|
||||
Labels: []string{"int gauge label key"},
|
||||
OptionalLabels: []string{"int gauge optional label key"},
|
||||
Default: true,
|
||||
})
|
||||
intAsyncHandle := estats.RegisterInt64AsyncGauge(estats.MetricDescriptor{
|
||||
Name: "async-gauge",
|
||||
Description: "async gauge value from test",
|
||||
Unit: "int",
|
||||
Labels: []string{"async label key"},
|
||||
OptionalLabels: []string{"async optional label key"},
|
||||
Default: true,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultTestTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Only float optional labels are configured, so only float optional labels should show up.
|
||||
// All required labels should show up.
|
||||
wantMetrics := []metricdata.Metrics{
|
||||
{
|
||||
Name: "int-counter-1",
|
||||
Description: "Sum of calls from test",
|
||||
Unit: "int",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("int counter 1 label key", "int counter 1 label value")), // No optional label, not float.
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "int-counter-3",
|
||||
Description: "sum of calls from test",
|
||||
Unit: "int",
|
||||
Data: metricdata.Sum[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("int counter 3 label key", "int counter 3 label value")), // No optional label, not float.
|
||||
Value: 4,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "float-counter",
|
||||
Description: "sum of calls from test",
|
||||
Unit: "float",
|
||||
Data: metricdata.Sum[float64]{
|
||||
DataPoints: []metricdata.DataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("float counter label key", "float counter label value"), attribute.String("float counter optional label key", "float counter optional label value")),
|
||||
Value: 1.2,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
IsMonotonic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "int-histo",
|
||||
Description: "histogram of call values from tests",
|
||||
Unit: "int",
|
||||
Data: metricdata.Histogram[int64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("int histo label key", "int histo label value")), // No optional label, not float.
|
||||
Count: 1,
|
||||
Bounds: bounds,
|
||||
BucketCounts: []uint64{0, 1, 0, 0},
|
||||
Min: metricdata.NewExtrema(int64(3)),
|
||||
Max: metricdata.NewExtrema(int64(3)),
|
||||
Sum: 3,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "float-histo",
|
||||
Description: "histogram of call values from tests",
|
||||
Unit: "float",
|
||||
Data: metricdata.Histogram[float64]{
|
||||
DataPoints: []metricdata.HistogramDataPoint[float64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("float histo label key", "float histo label value"), attribute.String("float histo optional label key", "float histo optional label value")),
|
||||
Count: 1,
|
||||
Bounds: bounds,
|
||||
BucketCounts: []uint64{0, 1, 0, 0},
|
||||
Min: metricdata.NewExtrema(float64(4.3)),
|
||||
Max: metricdata.NewExtrema(float64(4.3)),
|
||||
Sum: 4.3,
|
||||
},
|
||||
},
|
||||
Temporality: metricdata.CumulativeTemporality,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "simple-gauge",
|
||||
Description: "the most recent int emitted by test",
|
||||
Unit: "int",
|
||||
Data: metricdata.Gauge[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
Attributes: attribute.NewSet(attribute.String("int gauge label key", "int gauge label value")), // No optional label, not float.
|
||||
Value: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "async-gauge",
|
||||
Description: "async gauge value from test",
|
||||
Unit: "int",
|
||||
Data: metricdata.Gauge[int64]{
|
||||
DataPoints: []metricdata.DataPoint[int64]{
|
||||
{
|
||||
// Note: Only the required label is expected because optional labels
|
||||
// for "async optional label key" are not enabled in MetricsOptions below.
|
||||
Attributes: attribute.NewSet(attribute.String("async label key", "async label value")),
|
||||
Value: 999,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
constructor func(options MetricsOptions) metricsRecorderForTest
|
||||
}{
|
||||
{
|
||||
name: "client stats handler",
|
||||
constructor: newClientStatsHandler,
|
||||
},
|
||||
{
|
||||
name: "server stats handler",
|
||||
constructor: newServerStatsHandler,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := otelmetric.NewManualReader()
|
||||
provider := otelmetric.NewMeterProvider(otelmetric.WithReader(reader))
|
||||
|
||||
// This configures the defaults alongside int counter 3. All the instruments
|
||||
// registered except int counter 2 and 3 are default, so all measurements
|
||||
// recorded should show up in reader collected metrics except those for int
|
||||
// counter 2.
|
||||
// This also only toggles the float count and float histo optional labels,
|
||||
// so only those should show up in metrics emissions. All the required
|
||||
// labels should show up in metrics emissions.
|
||||
mo := MetricsOptions{
|
||||
Metrics: DefaultMetrics().Add("int-counter-3"),
|
||||
OptionalLabels: []string{"float counter optional label key", "float histo optional label key"},
|
||||
MeterProvider: provider,
|
||||
}
|
||||
mr := test.constructor(mo)
|
||||
mr.initializeMetrics()
|
||||
// These Record calls are guaranteed at a layer underneath OpenTelemetry for
|
||||
// labels emitted to match the length of labels + optional labels.
|
||||
intCountHandle1.Record(mr, 1, []string{"int counter 1 label value", "int counter 1 optional label value"}...)
|
||||
// int-counter-2 is not part of metrics specified (not default), so this
|
||||
// record call shouldn't show up in the reader.
|
||||
intCountHandle2.Record(mr, 2, []string{"int counter 2 label value", "int counter 2 optional label value"}...)
|
||||
// int-counter-3 is part of metrics specified, so this call should show up
|
||||
// in the reader.
|
||||
intCountHandle3.Record(mr, 4, []string{"int counter 3 label value", "int counter 3 optional label value"}...)
|
||||
|
||||
// All future recording points should show up in emissions as all of these are defaults.
|
||||
floatCountHandle.Record(mr, 1.2, []string{"float counter label value", "float counter optional label value"}...)
|
||||
intHistoHandle.Record(mr, 3, []string{"int histo label value", "int histo optional label value"}...)
|
||||
floatHistoHandle.Record(mr, 4.3, []string{"float histo label value", "float histo optional label value"}...)
|
||||
intGaugeHandle.Record(mr, 7, []string{"int gauge label value", "int gauge optional label value"}...)
|
||||
// This second gauge call should take the place of the previous gauge call.
|
||||
intGaugeHandle.Record(mr, 8, []string{"int gauge label value", "int gauge optional label value"}...)
|
||||
reporter := estats.AsyncMetricReporterFunc(func(r estats.AsyncMetricsRecorder) error {
|
||||
// We record value 999.
|
||||
// Note: We pass both required and optional labels, but the expectation (Step 2)
|
||||
// knows that the optional one will be dropped based on 'mo' config.
|
||||
intAsyncHandle.Record(r, 999, "async label value", "async optional label value")
|
||||
return nil
|
||||
})
|
||||
mr.RegisterAsyncReporter(reporter, intAsyncHandle)
|
||||
rm := &metricdata.ResourceMetrics{}
|
||||
reader.Collect(ctx, rm)
|
||||
gotMetrics := map[string]metricdata.Metrics{}
|
||||
for _, sm := range rm.ScopeMetrics {
|
||||
for _, m := range sm.Metrics {
|
||||
gotMetrics[m.Name] = m
|
||||
}
|
||||
}
|
||||
for _, metric := range wantMetrics {
|
||||
val, ok := gotMetrics[metric.Name]
|
||||
if !ok {
|
||||
t.Fatalf("Metric %v not present in recorded metrics", metric.Name)
|
||||
}
|
||||
if !metricdatatest.AssertEqual(t, metric, val, metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreExemplars()) {
|
||||
t.Fatalf("Metrics data type not equal for metric: %v", metric.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// int-counter-2 is not a default metric and wasn't specified in
|
||||
// constructor, so emissions should not show up.
|
||||
if _, ok := gotMetrics["int-counter-2"]; ok {
|
||||
t.Fatalf("Metric int-counter-2 present in recorded metrics, was not configured")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
626
stats/opentelemetry/opentelemetry.go
Normal file
626
stats/opentelemetry/opentelemetry.go
Normal file
@@ -0,0 +1,626 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Package opentelemetry implements opentelemetry instrumentation code for
|
||||
// gRPC-Go clients and servers.
|
||||
//
|
||||
// For details on configuring opentelemetry and various instruments that this
|
||||
// package creates, see
|
||||
// [gRPC OpenTelemetry Metrics](https://grpc.io/docs/guides/opentelemetry-metrics/).
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
otelattribute "go.opentelemetry.io/otel/attribute"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
experimental "google.golang.org/grpc/experimental/opentelemetry"
|
||||
estats "google.golang.org/grpc/experimental/stats"
|
||||
"google.golang.org/grpc/grpclog"
|
||||
"google.golang.org/grpc/internal"
|
||||
"google.golang.org/grpc/stats"
|
||||
otelinternal "google.golang.org/grpc/stats/opentelemetry/internal"
|
||||
)
|
||||
|
||||
func init() {
|
||||
otelinternal.SetPluginOption = func(o *Options, po otelinternal.PluginOption) {
|
||||
o.MetricsOptions.pluginOption = po
|
||||
// Log an error if one of the options is missing.
|
||||
if (o.TraceOptions.TextMapPropagator == nil) != (o.TraceOptions.TracerProvider == nil) {
|
||||
logger.Warning("Tracing will not be recorded because traceOptions are not set properly: one of TextMapPropagator or TracerProvider is missing")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
logger = grpclog.Component("otel-plugin")
|
||||
canonicalString = internal.CanonicalString.(func(codes.Code) string)
|
||||
joinDialOptions = internal.JoinDialOptions.(func(...grpc.DialOption) grpc.DialOption)
|
||||
)
|
||||
|
||||
// Options are the options for OpenTelemetry instrumentation.
|
||||
type Options struct {
|
||||
// MetricsOptions are the metrics options for OpenTelemetry instrumentation.
|
||||
MetricsOptions MetricsOptions
|
||||
// TraceOptions are the tracing options for OpenTelemetry instrumentation.
|
||||
TraceOptions experimental.TraceOptions
|
||||
}
|
||||
|
||||
func (o *Options) isMetricsEnabled() bool {
|
||||
return o.MetricsOptions.MeterProvider != nil
|
||||
}
|
||||
|
||||
func (o *Options) isTracingEnabled() bool {
|
||||
return o.TraceOptions.TracerProvider != nil
|
||||
}
|
||||
|
||||
// MetricsOptions are the metrics options for OpenTelemetry instrumentation.
|
||||
type MetricsOptions struct {
|
||||
// MeterProvider is the MeterProvider instance that will be used to create
|
||||
// instruments. To enable metrics collection, set a meter provider. If
|
||||
// unset, no metrics will be recorded.
|
||||
MeterProvider otelmetric.MeterProvider
|
||||
|
||||
// Metrics are the metrics to instrument. Will create instrument and record telemetry
|
||||
// for corresponding metric supported by the client and server
|
||||
// instrumentation components if applicable. If not set, the default metrics
|
||||
// will be recorded.
|
||||
Metrics *stats.MetricSet
|
||||
|
||||
// MethodAttributeFilter is a function that determines whether to record the
|
||||
// method name of RPCs as an attribute, or to bucket into "other". Take care
|
||||
// to limit the values allowed, as allowing too many will increase
|
||||
// cardinality and could cause severe memory or performance problems.
|
||||
//
|
||||
// This only applies for server-side metrics. For clients, to record the
|
||||
// method name in the attributes, pass grpc.StaticMethodCallOption to Invoke
|
||||
// or NewStream. Note that when using protobuf generated clients, this
|
||||
// CallOption is included automatically.
|
||||
MethodAttributeFilter func(string) bool
|
||||
|
||||
// OptionalLabels specifies a list of optional labels to enable on any
|
||||
// metrics that support them.
|
||||
OptionalLabels []string
|
||||
|
||||
// pluginOption is used to get labels to attach to certain metrics, if set.
|
||||
pluginOption otelinternal.PluginOption
|
||||
}
|
||||
|
||||
// DialOption returns a dial option which enables OpenTelemetry instrumentation
|
||||
// code for a grpc.ClientConn.
|
||||
//
|
||||
// Client applications interested in instrumenting their grpc.ClientConn should
|
||||
// pass the dial option returned from this function as a dial option to
|
||||
// grpc.NewClient().
|
||||
//
|
||||
// For the metrics supported by this instrumentation code, specify the client
|
||||
// metrics to record in metrics options. Also provide an implementation of a
|
||||
// MeterProvider. If the passed in Meter Provider does not have the view
|
||||
// configured for an individual metric turned on, the API call in this component
|
||||
// will create a default view for that metric.
|
||||
//
|
||||
// For the traces supported by this instrumentation code, provide an
|
||||
// implementation of a TextMapPropagator and OpenTelemetry TracerProvider.
|
||||
func DialOption(o Options) grpc.DialOption {
|
||||
var metricsOpts, tracingOpts []grpc.DialOption
|
||||
|
||||
if o.isMetricsEnabled() {
|
||||
metricsHandler := &clientMetricsHandler{options: o}
|
||||
metricsHandler.initializeMetrics()
|
||||
metricsOpts = append(metricsOpts, grpc.WithChainUnaryInterceptor(metricsHandler.unaryInterceptor), grpc.WithChainStreamInterceptor(metricsHandler.streamInterceptor), grpc.WithStatsHandler(metricsHandler))
|
||||
}
|
||||
if o.isTracingEnabled() {
|
||||
tracingHandler := &clientTracingHandler{options: o}
|
||||
tracingHandler.initializeTraces()
|
||||
tracingOpts = append(tracingOpts, grpc.WithChainUnaryInterceptor(tracingHandler.unaryInterceptor), grpc.WithChainStreamInterceptor(tracingHandler.streamInterceptor), grpc.WithStatsHandler(tracingHandler))
|
||||
}
|
||||
return joinDialOptions(append(metricsOpts, tracingOpts...)...)
|
||||
}
|
||||
|
||||
var joinServerOptions = internal.JoinServerOptions.(func(...grpc.ServerOption) grpc.ServerOption)
|
||||
|
||||
// ServerOption returns a server option which enables OpenTelemetry
|
||||
// instrumentation code for a grpc.Server.
|
||||
//
|
||||
// Server applications interested in instrumenting their grpc.Server should pass
|
||||
// the server option returned from this function as an argument to
|
||||
// grpc.NewServer().
|
||||
//
|
||||
// For the metrics supported by this instrumentation code, specify the server
|
||||
// metrics to record in metrics options. Also provide an implementation of a
|
||||
// MeterProvider. If the passed in Meter Provider does not have the view
|
||||
// configured for an individual metric turned on, the API call in this component
|
||||
// will create a default view for that metric.
|
||||
//
|
||||
// For the traces supported by this instrumentation code, provide an
|
||||
// implementation of a TextMapPropagator and OpenTelemetry TracerProvider.
|
||||
func ServerOption(o Options) grpc.ServerOption {
|
||||
var metricsOpts, tracingOpts []grpc.ServerOption
|
||||
|
||||
if o.isMetricsEnabled() {
|
||||
metricsHandler := &serverMetricsHandler{options: o}
|
||||
metricsHandler.initializeMetrics()
|
||||
metricsOpts = append(metricsOpts, grpc.ChainUnaryInterceptor(metricsHandler.unaryInterceptor), grpc.ChainStreamInterceptor(metricsHandler.streamInterceptor), grpc.StatsHandler(metricsHandler))
|
||||
}
|
||||
if o.isTracingEnabled() {
|
||||
tracingHandler := &serverTracingHandler{options: o}
|
||||
tracingHandler.initializeTraces()
|
||||
tracingOpts = append(tracingOpts, grpc.StatsHandler(tracingHandler))
|
||||
}
|
||||
return joinServerOptions(append(metricsOpts, tracingOpts...)...)
|
||||
}
|
||||
|
||||
// callInfo is information pertaining to the lifespan of the RPC client side.
|
||||
type callInfo struct {
|
||||
target string
|
||||
|
||||
method string
|
||||
|
||||
// nameResolutionEventAdded is set when the resolver delay trace event
|
||||
// is added. Prevents duplicate events, since it is reported per-attempt.
|
||||
nameResolutionEventAdded atomic.Bool
|
||||
// previousRPCAttempts holds the count of RPC attempts that have happened
|
||||
// before current attempt. Transparent retries are excluded.
|
||||
previousRPCAttempts atomic.Uint32
|
||||
}
|
||||
|
||||
type callInfoKey struct{}
|
||||
|
||||
// getCallInfo returns the callInfo stored in the context, or nil
|
||||
// if there isn't one.
|
||||
func getCallInfo(ctx context.Context) *callInfo {
|
||||
ci, _ := ctx.Value(callInfoKey{}).(*callInfo)
|
||||
return ci
|
||||
}
|
||||
|
||||
// rpcInfo is RPC information scoped to the RPC attempt life span client side,
|
||||
// and the RPC life span server side.
|
||||
type rpcInfo struct {
|
||||
ai *attemptInfo
|
||||
}
|
||||
|
||||
type clientRPCInfoKey struct{}
|
||||
type serverRPCInfoKey struct{}
|
||||
|
||||
// clientRPCInfo returns the rpcInfo stored in the context for client, or nil
|
||||
// if there isn't one.
|
||||
func clientRPCInfo(ctx context.Context) *rpcInfo {
|
||||
ri, _ := ctx.Value(clientRPCInfoKey{}).(*rpcInfo)
|
||||
return ri
|
||||
}
|
||||
|
||||
// serverRPCInfo returns the rpcInfo stored in the context for server, or nil
|
||||
// if there isn't one.
|
||||
func serverRPCInfo(ctx context.Context) *rpcInfo {
|
||||
ri, _ := ctx.Value(serverRPCInfoKey{}).(*rpcInfo)
|
||||
return ri
|
||||
}
|
||||
|
||||
func getOrCreateClientRPCInfo(ctx context.Context, info *stats.RPCTagInfo) (context.Context, *rpcInfo) {
|
||||
ri := clientRPCInfo(ctx)
|
||||
if ri != nil {
|
||||
return ctx, ri
|
||||
}
|
||||
ri = &rpcInfo{
|
||||
ai: &attemptInfo{
|
||||
startTime: time.Now(),
|
||||
method: removeLeadingSlash(info.FullMethodName),
|
||||
xdsLabels: map[string]string{
|
||||
// The defaults for all the per call labels from a plugin that
|
||||
// executes on the callpath that this OpenTelemetry component
|
||||
// currently supports.
|
||||
"grpc.lb.locality": "",
|
||||
"grpc.lb.backend_service": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
return context.WithValue(ctx, clientRPCInfoKey{}, ri), ri
|
||||
}
|
||||
|
||||
func getOrCreateServerRPCInfo(ctx context.Context, info *stats.RPCTagInfo, options MetricsOptions) (context.Context, *rpcInfo) {
|
||||
ri := serverRPCInfo(ctx)
|
||||
if ri != nil {
|
||||
return ctx, ri
|
||||
}
|
||||
method := info.FullMethodName
|
||||
if options.MethodAttributeFilter != nil {
|
||||
if !options.MethodAttributeFilter(method) {
|
||||
method = "other"
|
||||
}
|
||||
}
|
||||
server := internal.ServerFromContext.(func(context.Context) *grpc.Server)(ctx)
|
||||
if server == nil { // Shouldn't happen, defensive programming.
|
||||
logger.Error("ctx passed into server side stats handler has no grpc server ref")
|
||||
method = "other"
|
||||
} else {
|
||||
isRegisteredMethod := internal.IsRegisteredMethod.(func(*grpc.Server, string) bool)
|
||||
if !isRegisteredMethod(server, method) {
|
||||
method = "other"
|
||||
}
|
||||
}
|
||||
|
||||
ri = &rpcInfo{
|
||||
ai: &attemptInfo{
|
||||
startTime: time.Now(),
|
||||
method: removeLeadingSlash(method),
|
||||
},
|
||||
}
|
||||
return context.WithValue(ctx, serverRPCInfoKey{}, ri), ri
|
||||
}
|
||||
|
||||
func removeLeadingSlash(mn string) string {
|
||||
return strings.TrimLeft(mn, "/")
|
||||
}
|
||||
|
||||
// attemptInfo is RPC information scoped to the RPC attempt life span client
|
||||
// side, and the RPC life span server side.
|
||||
type attemptInfo struct {
|
||||
// access these counts atomically for hedging in the future:
|
||||
// number of bytes after compression (within each message) from side (client
|
||||
// || server).
|
||||
sentCompressedBytes int64
|
||||
// number of compressed bytes received (within each message) received on
|
||||
// side (client || server).
|
||||
recvCompressedBytes int64
|
||||
|
||||
startTime time.Time
|
||||
method string
|
||||
|
||||
pluginOptionLabels map[string]string // pluginOptionLabels to attach to metrics emitted
|
||||
xdsLabels map[string]string
|
||||
|
||||
// traceSpan is data used for recording traces.
|
||||
traceSpan trace.Span
|
||||
// message counters for sent and received messages (used for
|
||||
// generating message IDs), and the number of previous RPC attempts for the
|
||||
// associated call.
|
||||
countSentMsg uint32
|
||||
countRecvMsg uint32
|
||||
}
|
||||
|
||||
type clientMetrics struct {
|
||||
// "grpc.client.attempt.started"
|
||||
attemptStarted otelmetric.Int64Counter
|
||||
// "grpc.client.attempt.duration"
|
||||
attemptDuration otelmetric.Float64Histogram
|
||||
// "grpc.client.attempt.sent_total_compressed_message_size"
|
||||
attemptSentTotalCompressedMessageSize otelmetric.Int64Histogram
|
||||
// "grpc.client.attempt.rcvd_total_compressed_message_size"
|
||||
attemptRcvdTotalCompressedMessageSize otelmetric.Int64Histogram
|
||||
// "grpc.client.call.duration"
|
||||
callDuration otelmetric.Float64Histogram
|
||||
}
|
||||
|
||||
type serverMetrics struct {
|
||||
// "grpc.server.call.started"
|
||||
callStarted otelmetric.Int64Counter
|
||||
// "grpc.server.call.sent_total_compressed_message_size"
|
||||
callSentTotalCompressedMessageSize otelmetric.Int64Histogram
|
||||
// "grpc.server.call.rcvd_total_compressed_message_size"
|
||||
callRcvdTotalCompressedMessageSize otelmetric.Int64Histogram
|
||||
// "grpc.server.call.duration"
|
||||
callDuration otelmetric.Float64Histogram
|
||||
}
|
||||
|
||||
func createInt64Counter(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Int64CounterOption) otelmetric.Int64Counter {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Int64Counter{}
|
||||
}
|
||||
ret, err := meter.Int64Counter(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Int64Counter{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func createInt64UpDownCounter(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Int64UpDownCounterOption) otelmetric.Int64UpDownCounter {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Int64UpDownCounter{}
|
||||
}
|
||||
ret, err := meter.Int64UpDownCounter(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("Failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Int64UpDownCounter{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func createFloat64Counter(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Float64CounterOption) otelmetric.Float64Counter {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Float64Counter{}
|
||||
}
|
||||
ret, err := meter.Float64Counter(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Float64Counter{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func createInt64Histogram(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Int64HistogramOption) otelmetric.Int64Histogram {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Int64Histogram{}
|
||||
}
|
||||
ret, err := meter.Int64Histogram(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Int64Histogram{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func createFloat64Histogram(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Float64HistogramOption) otelmetric.Float64Histogram {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Float64Histogram{}
|
||||
}
|
||||
ret, err := meter.Float64Histogram(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Float64Histogram{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func createInt64Gauge(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Int64GaugeOption) otelmetric.Int64Gauge {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
return noop.Int64Gauge{}
|
||||
}
|
||||
ret, err := meter.Int64Gauge(string(metricName), options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric \"%v\", will not record: %v", metricName, err)
|
||||
return noop.Int64Gauge{}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// createInt64ObservableGauge initializes an OTel Int64ObservableGauge if the metric is enabled.
|
||||
func createInt64ObservableGauge(setOfMetrics map[string]bool, metricName string, meter otelmetric.Meter, options ...otelmetric.Int64ObservableGaugeOption) otelmetric.Int64ObservableGauge {
|
||||
if _, ok := setOfMetrics[metricName]; !ok {
|
||||
n, _ := noop.NewMeterProvider().Meter("noop").Int64ObservableGauge("noop")
|
||||
return n
|
||||
}
|
||||
ret, err := meter.Int64ObservableGauge(metricName, options...)
|
||||
if err != nil {
|
||||
logger.Errorf("failed to register metric %q, will not record: %v", metricName, err)
|
||||
n, _ := noop.NewMeterProvider().Meter("noop").Int64ObservableGauge("noop")
|
||||
return n
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func optionFromLabels(labelKeys []string, optionalLabelKeys []string, optionalLabels []string, labelVals ...string) otelmetric.MeasurementOption {
|
||||
var attributes []otelattribute.KeyValue
|
||||
|
||||
// Once it hits here lower level has guaranteed length of labelVals matches
|
||||
// labelKeys + optionalLabelKeys.
|
||||
for i, label := range labelKeys {
|
||||
attributes = append(attributes, otelattribute.String(label, labelVals[i]))
|
||||
}
|
||||
|
||||
for i, label := range optionalLabelKeys {
|
||||
for _, optLabel := range optionalLabels { // o(n) could build out a set but n is currently capped at < 5
|
||||
if label == optLabel {
|
||||
attributes = append(attributes, otelattribute.String(label, labelVals[i+len(labelKeys)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return otelmetric.WithAttributeSet(otelattribute.NewSet(attributes...))
|
||||
}
|
||||
|
||||
// registryMetrics implements MetricsRecorder for the client and server stats
|
||||
// handlers.
|
||||
type registryMetrics struct {
|
||||
internal.EnforceMetricsRecorderEmbedding
|
||||
intCounts map[*estats.MetricDescriptor]otelmetric.Int64Counter
|
||||
floatCounts map[*estats.MetricDescriptor]otelmetric.Float64Counter
|
||||
intHistos map[*estats.MetricDescriptor]otelmetric.Int64Histogram
|
||||
floatHistos map[*estats.MetricDescriptor]otelmetric.Float64Histogram
|
||||
intGauges map[*estats.MetricDescriptor]otelmetric.Int64Gauge
|
||||
intUpDownCounts map[*estats.MetricDescriptor]otelmetric.Int64UpDownCounter
|
||||
|
||||
// Asynchronous (Observable) Instruments
|
||||
intObservableGauges map[*estats.MetricDescriptor]otelmetric.Int64ObservableGauge
|
||||
|
||||
meter otelmetric.Meter
|
||||
optionalLabels []string
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) registerMetrics(metrics *stats.MetricSet, meter otelmetric.Meter) {
|
||||
rm.meter = meter
|
||||
rm.intCounts = make(map[*estats.MetricDescriptor]otelmetric.Int64Counter)
|
||||
rm.floatCounts = make(map[*estats.MetricDescriptor]otelmetric.Float64Counter)
|
||||
rm.intHistos = make(map[*estats.MetricDescriptor]otelmetric.Int64Histogram)
|
||||
rm.floatHistos = make(map[*estats.MetricDescriptor]otelmetric.Float64Histogram)
|
||||
rm.intGauges = make(map[*estats.MetricDescriptor]otelmetric.Int64Gauge)
|
||||
rm.intUpDownCounts = make(map[*estats.MetricDescriptor]otelmetric.Int64UpDownCounter)
|
||||
rm.intObservableGauges = make(map[*estats.MetricDescriptor]otelmetric.Int64ObservableGauge)
|
||||
|
||||
for metric := range metrics.Metrics() {
|
||||
desc := estats.DescriptorForMetric(metric)
|
||||
if desc == nil {
|
||||
// Either the metric was per call or the metric is not registered.
|
||||
// Thus, if this component ever receives the desc as a handle in
|
||||
// record it will be a no-op.
|
||||
continue
|
||||
}
|
||||
switch desc.Type {
|
||||
case estats.MetricTypeIntCount:
|
||||
rm.intCounts[desc] = createInt64Counter(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description))
|
||||
case estats.MetricTypeFloatCount:
|
||||
rm.floatCounts[desc] = createFloat64Counter(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description))
|
||||
case estats.MetricTypeIntHisto:
|
||||
rm.intHistos[desc] = createInt64Histogram(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description), otelmetric.WithExplicitBucketBoundaries(desc.Bounds...))
|
||||
case estats.MetricTypeFloatHisto:
|
||||
rm.floatHistos[desc] = createFloat64Histogram(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description), otelmetric.WithExplicitBucketBoundaries(desc.Bounds...))
|
||||
case estats.MetricTypeIntGauge:
|
||||
rm.intGauges[desc] = createInt64Gauge(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description))
|
||||
case estats.MetricTypeIntUpDownCount:
|
||||
rm.intUpDownCounts[desc] = createInt64UpDownCounter(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description))
|
||||
case estats.MetricTypeIntAsyncGauge:
|
||||
rm.intObservableGauges[desc] = createInt64ObservableGauge(metrics.Metrics(), desc.Name, meter, otelmetric.WithUnit(desc.Unit), otelmetric.WithDescription(desc.Description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordInt64Count(handle *estats.Int64CountHandle, incr int64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if ic, ok := rm.intCounts[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
ic.Add(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordInt64UpDownCount(handle *estats.Int64UpDownCountHandle, incr int64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if ic, ok := rm.intUpDownCounts[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
ic.Add(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordFloat64Count(handle *estats.Float64CountHandle, incr float64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if fc, ok := rm.floatCounts[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
fc.Add(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordInt64Histo(handle *estats.Int64HistoHandle, incr int64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if ih, ok := rm.intHistos[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
ih.Record(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordFloat64Histo(handle *estats.Float64HistoHandle, incr float64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if fh, ok := rm.floatHistos[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
fh.Record(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
func (rm *registryMetrics) RecordInt64Gauge(handle *estats.Int64GaugeHandle, incr int64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
if ig, ok := rm.intGauges[desc]; ok {
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, rm.optionalLabels, labels...)
|
||||
ig.Record(context.TODO(), incr, ao)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAsyncReporter will register a callback with the underlying OpenTelemetry
|
||||
// Meter for the provided descriptors.
|
||||
//
|
||||
// It will map the provided descriptors to their corresponding OTel Observable
|
||||
// instruments. If no instruments match the descriptors, registration is
|
||||
// skipped.
|
||||
//
|
||||
// The returned cleanup function unregisters the callback from the Meter.
|
||||
// RegisterAsyncReporter registers a callback with the OpenTelemetry Meter.
|
||||
func (rm *registryMetrics) RegisterAsyncReporter(reporter estats.AsyncMetricReporter, metrics ...estats.AsyncMetric) func() {
|
||||
observables := make([]otelmetric.Observable, 0, len(metrics))
|
||||
observableMap := make(map[*estats.MetricDescriptor]otelmetric.Observable, len(metrics))
|
||||
|
||||
for _, m := range metrics {
|
||||
d := m.Descriptor()
|
||||
if inst, ok := rm.intObservableGauges[d]; ok {
|
||||
observables = append(observables, inst)
|
||||
observableMap[d] = inst
|
||||
}
|
||||
}
|
||||
|
||||
if len(observables) == 0 {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
cbWrapper := func(_ context.Context, o otelmetric.Observer) error {
|
||||
adapter := &observerAdapter{
|
||||
observableMap: observableMap,
|
||||
optionalLabels: rm.optionalLabels,
|
||||
delegate: o,
|
||||
}
|
||||
reporter.Report(adapter)
|
||||
return nil
|
||||
}
|
||||
|
||||
reg, err := rm.meter.RegisterCallback(cbWrapper, observables...)
|
||||
if err != nil {
|
||||
logger.Warningf("grpc: failed to register callback for async metrics: %v", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
return func() {
|
||||
err = reg.Unregister()
|
||||
if err != nil {
|
||||
logger.Errorf("grpc: failed to unregister callback for async metrics: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Users of this component should use these bucket boundaries as part of their
|
||||
// SDK MeterProvider passed in. This component sends this as "advice" to the
|
||||
// API, which works, however this stability is not guaranteed, so for safety the
|
||||
// SDK Meter Provider provided should set these bounds for corresponding
|
||||
// metrics.
|
||||
var (
|
||||
// DefaultLatencyBounds are the default bounds for latency metrics.
|
||||
DefaultLatencyBounds = []float64{0, 0.00001, 0.00005, 0.0001, 0.0003, 0.0006, 0.0008, 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.01, 0.013, 0.016, 0.02, 0.025, 0.03, 0.04, 0.05, 0.065, 0.08, 0.1, 0.13, 0.16, 0.2, 0.25, 0.3, 0.4, 0.5, 0.65, 0.8, 1, 2, 5, 10, 20, 50, 100} // provide "advice" through API, SDK should set this too
|
||||
// DefaultSizeBounds are the default bounds for metrics which record size.
|
||||
DefaultSizeBounds = []float64{0, 1024, 2048, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864, 268435456, 1073741824, 4294967296}
|
||||
// defaultPerCallMetrics are the default metrics provided by this module.
|
||||
defaultPerCallMetrics = stats.NewMetricSet(ClientAttemptStartedMetricName, ClientAttemptDurationMetricName, ClientAttemptSentCompressedTotalMessageSizeMetricName, ClientAttemptRcvdCompressedTotalMessageSizeMetricName, ClientCallDurationMetricName, ServerCallStartedMetricName, ServerCallSentCompressedTotalMessageSizeMetricName, ServerCallRcvdCompressedTotalMessageSizeMetricName, ServerCallDurationMetricName)
|
||||
)
|
||||
|
||||
// DefaultMetrics returns a set of default OpenTelemetry metrics.
|
||||
//
|
||||
// This should only be invoked after init time.
|
||||
func DefaultMetrics() *stats.MetricSet {
|
||||
return defaultPerCallMetrics.Join(estats.DefaultMetrics)
|
||||
}
|
||||
|
||||
type observerAdapter struct {
|
||||
observableMap map[*estats.MetricDescriptor]otelmetric.Observable
|
||||
optionalLabels []string
|
||||
delegate otelmetric.Observer
|
||||
}
|
||||
|
||||
// RecordInt64AsyncGauge records the measurement alongside labels on the int
|
||||
// gauge associated with the provided handle.
|
||||
func (a *observerAdapter) RecordInt64AsyncGauge(handle *estats.Int64AsyncGaugeHandle, val int64, labels ...string) {
|
||||
desc := handle.Descriptor()
|
||||
observable, ok := a.observableMap[desc]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ao := optionFromLabels(desc.Labels, desc.OptionalLabels, a.optionalLabels, labels...)
|
||||
|
||||
switch obs := observable.(type) {
|
||||
case otelmetric.Int64ObservableGauge:
|
||||
a.delegate.ObserveInt64(obs, val, ao)
|
||||
default:
|
||||
}
|
||||
}
|
||||
254
stats/opentelemetry/server_metrics.go
Normal file
254
stats/opentelemetry/server_metrics.go
Normal file
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
otelattribute "go.opentelemetry.io/otel/attribute"
|
||||
otelmetric "go.opentelemetry.io/otel/metric"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
estats "google.golang.org/grpc/experimental/stats"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type serverMetricsHandler struct {
|
||||
estats.MetricsRecorder
|
||||
options Options
|
||||
serverMetrics serverMetrics
|
||||
}
|
||||
|
||||
func (h *serverMetricsHandler) initializeMetrics() {
|
||||
// Will set no metrics to record, logically making this stats handler a
|
||||
// no-op.
|
||||
if h.options.MetricsOptions.MeterProvider == nil {
|
||||
return
|
||||
}
|
||||
|
||||
meter := h.options.MetricsOptions.MeterProvider.Meter("grpc-go", otelmetric.WithInstrumentationVersion(grpc.Version))
|
||||
if meter == nil {
|
||||
return
|
||||
}
|
||||
metrics := h.options.MetricsOptions.Metrics
|
||||
if metrics == nil {
|
||||
metrics = DefaultMetrics()
|
||||
}
|
||||
|
||||
h.serverMetrics.callStarted = createInt64Counter(metrics.Metrics(), "grpc.server.call.started", meter, otelmetric.WithUnit("{call}"), otelmetric.WithDescription("Number of server calls started."))
|
||||
h.serverMetrics.callSentTotalCompressedMessageSize = createInt64Histogram(metrics.Metrics(), "grpc.server.call.sent_total_compressed_message_size", meter, otelmetric.WithUnit("By"), otelmetric.WithDescription("Compressed message bytes sent per server call."), otelmetric.WithExplicitBucketBoundaries(DefaultSizeBounds...))
|
||||
h.serverMetrics.callRcvdTotalCompressedMessageSize = createInt64Histogram(metrics.Metrics(), "grpc.server.call.rcvd_total_compressed_message_size", meter, otelmetric.WithUnit("By"), otelmetric.WithDescription("Compressed message bytes received per server call."), otelmetric.WithExplicitBucketBoundaries(DefaultSizeBounds...))
|
||||
h.serverMetrics.callDuration = createFloat64Histogram(metrics.Metrics(), "grpc.server.call.duration", meter, otelmetric.WithUnit("s"), otelmetric.WithDescription("End-to-end time taken to complete a call from server transport's perspective."), otelmetric.WithExplicitBucketBoundaries(DefaultLatencyBounds...))
|
||||
|
||||
rm := ®istryMetrics{
|
||||
optionalLabels: h.options.MetricsOptions.OptionalLabels,
|
||||
}
|
||||
h.MetricsRecorder = rm
|
||||
rm.registerMetrics(metrics, meter)
|
||||
}
|
||||
|
||||
// attachLabelsTransportStream intercepts SetHeader and SendHeader calls of the
|
||||
// underlying ServerTransportStream to attach metadataExchangeLabels.
|
||||
type attachLabelsTransportStream struct {
|
||||
grpc.ServerTransportStream
|
||||
|
||||
attachedLabels atomic.Bool
|
||||
metadataExchangeLabels metadata.MD
|
||||
}
|
||||
|
||||
func (s *attachLabelsTransportStream) SetHeader(md metadata.MD) error {
|
||||
if !s.attachedLabels.Swap(true) {
|
||||
s.ServerTransportStream.SetHeader(s.metadataExchangeLabels)
|
||||
}
|
||||
return s.ServerTransportStream.SetHeader(md)
|
||||
}
|
||||
|
||||
func (s *attachLabelsTransportStream) SendHeader(md metadata.MD) error {
|
||||
if !s.attachedLabels.Swap(true) {
|
||||
s.ServerTransportStream.SetHeader(s.metadataExchangeLabels)
|
||||
}
|
||||
|
||||
return s.ServerTransportStream.SendHeader(md)
|
||||
}
|
||||
|
||||
func (h *serverMetricsHandler) unaryInterceptor(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
var metadataExchangeLabels metadata.MD
|
||||
if h.options.MetricsOptions.pluginOption != nil {
|
||||
metadataExchangeLabels = h.options.MetricsOptions.pluginOption.GetMetadata()
|
||||
}
|
||||
|
||||
sts := grpc.ServerTransportStreamFromContext(ctx)
|
||||
|
||||
alts := &attachLabelsTransportStream{
|
||||
ServerTransportStream: sts,
|
||||
metadataExchangeLabels: metadataExchangeLabels,
|
||||
}
|
||||
ctx = grpc.NewContextWithServerTransportStream(ctx, alts)
|
||||
|
||||
res, err := handler(ctx, req)
|
||||
if err != nil { // maybe trailers-only if headers haven't already been sent
|
||||
if !alts.attachedLabels.Swap(true) {
|
||||
alts.SetTrailer(alts.metadataExchangeLabels)
|
||||
}
|
||||
} else { // headers will be written; a message was sent
|
||||
if !alts.attachedLabels.Swap(true) {
|
||||
alts.SetHeader(alts.metadataExchangeLabels)
|
||||
}
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// attachLabelsStream embeds a grpc.ServerStream, and intercepts the
|
||||
// SetHeader/SendHeader/SendMsg/SendTrailer call to attach metadata exchange
|
||||
// labels.
|
||||
type attachLabelsStream struct {
|
||||
grpc.ServerStream
|
||||
|
||||
attachedLabels atomic.Bool
|
||||
metadataExchangeLabels metadata.MD
|
||||
}
|
||||
|
||||
func (s *attachLabelsStream) SetHeader(md metadata.MD) error {
|
||||
if !s.attachedLabels.Swap(true) {
|
||||
s.ServerStream.SetHeader(s.metadataExchangeLabels)
|
||||
}
|
||||
|
||||
return s.ServerStream.SetHeader(md)
|
||||
}
|
||||
|
||||
func (s *attachLabelsStream) SendHeader(md metadata.MD) error {
|
||||
if !s.attachedLabels.Swap(true) {
|
||||
s.ServerStream.SetHeader(s.metadataExchangeLabels)
|
||||
}
|
||||
|
||||
return s.ServerStream.SendHeader(md)
|
||||
}
|
||||
|
||||
func (s *attachLabelsStream) SendMsg(m any) error {
|
||||
if !s.attachedLabels.Swap(true) {
|
||||
s.ServerStream.SetHeader(s.metadataExchangeLabels)
|
||||
}
|
||||
return s.ServerStream.SendMsg(m)
|
||||
}
|
||||
|
||||
func (h *serverMetricsHandler) streamInterceptor(srv any, ss grpc.ServerStream, _ *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
|
||||
var metadataExchangeLabels metadata.MD
|
||||
if h.options.MetricsOptions.pluginOption != nil {
|
||||
metadataExchangeLabels = h.options.MetricsOptions.pluginOption.GetMetadata()
|
||||
}
|
||||
als := &attachLabelsStream{
|
||||
ServerStream: ss,
|
||||
metadataExchangeLabels: metadataExchangeLabels,
|
||||
}
|
||||
err := handler(srv, als)
|
||||
|
||||
// Add metadata exchange labels to trailers if never sent in headers,
|
||||
// irrespective of whether or not RPC failed.
|
||||
if !als.attachedLabels.Load() {
|
||||
als.SetTrailer(als.metadataExchangeLabels)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler.
|
||||
func (h *serverMetricsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler.
|
||||
func (h *serverMetricsHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
|
||||
// TagRPC implements per RPC context management for metrics.
|
||||
func (h *serverMetricsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
|
||||
ctx, _ = getOrCreateServerRPCInfo(ctx, info, h.options.MetricsOptions)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleRPC handles per RPC stats implementation.
|
||||
func (h *serverMetricsHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := serverRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
logger.Error("ctx passed into server side stats handler metrics event handling has no server call data present")
|
||||
return
|
||||
}
|
||||
h.processRPCData(ctx, rs, ri.ai)
|
||||
}
|
||||
|
||||
func (h *serverMetricsHandler) processRPCData(ctx context.Context, s stats.RPCStats, ai *attemptInfo) {
|
||||
switch st := s.(type) {
|
||||
case *stats.InHeader:
|
||||
if ai.pluginOptionLabels == nil && h.options.MetricsOptions.pluginOption != nil {
|
||||
labels := h.options.MetricsOptions.pluginOption.GetLabels(st.Header)
|
||||
if labels == nil {
|
||||
labels = map[string]string{} // Shouldn't return a nil map. Make it empty if so to ignore future Get Calls for this Attempt.
|
||||
}
|
||||
ai.pluginOptionLabels = labels
|
||||
}
|
||||
attrs := otelmetric.WithAttributeSet(otelattribute.NewSet(
|
||||
otelattribute.String("grpc.method", ai.method),
|
||||
))
|
||||
h.serverMetrics.callStarted.Add(ctx, 1, attrs)
|
||||
case *stats.OutPayload:
|
||||
atomic.AddInt64(&ai.sentCompressedBytes, int64(st.CompressedLength))
|
||||
case *stats.InPayload:
|
||||
atomic.AddInt64(&ai.recvCompressedBytes, int64(st.CompressedLength))
|
||||
case *stats.End:
|
||||
h.processRPCEnd(ctx, ai, st)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (h *serverMetricsHandler) processRPCEnd(ctx context.Context, ai *attemptInfo, e *stats.End) {
|
||||
latency := float64(time.Since(ai.startTime)) / float64(time.Second)
|
||||
st := "OK"
|
||||
if e.Error != nil {
|
||||
s, _ := status.FromError(e.Error)
|
||||
st = canonicalString(s.Code())
|
||||
}
|
||||
attributes := []otelattribute.KeyValue{
|
||||
otelattribute.String("grpc.method", ai.method),
|
||||
otelattribute.String("grpc.status", st),
|
||||
}
|
||||
for k, v := range ai.pluginOptionLabels {
|
||||
attributes = append(attributes, otelattribute.String(k, v))
|
||||
}
|
||||
|
||||
// Allocate vararg slice once.
|
||||
opts := []otelmetric.RecordOption{otelmetric.WithAttributeSet(otelattribute.NewSet(attributes...))}
|
||||
h.serverMetrics.callDuration.Record(ctx, latency, opts...)
|
||||
h.serverMetrics.callSentTotalCompressedMessageSize.Record(ctx, atomic.LoadInt64(&ai.sentCompressedBytes), opts...)
|
||||
h.serverMetrics.callRcvdTotalCompressedMessageSize.Record(ctx, atomic.LoadInt64(&ai.recvCompressedBytes), opts...)
|
||||
}
|
||||
|
||||
const (
|
||||
// ServerCallStartedMetricName is the number of server calls started.
|
||||
ServerCallStartedMetricName string = "grpc.server.call.started"
|
||||
// ServerCallSentCompressedTotalMessageSizeMetricName is the compressed
|
||||
// message bytes sent per server call.
|
||||
ServerCallSentCompressedTotalMessageSizeMetricName string = "grpc.server.call.sent_total_compressed_message_size"
|
||||
// ServerCallRcvdCompressedTotalMessageSizeMetricName is the compressed
|
||||
// message bytes received per server call.
|
||||
ServerCallRcvdCompressedTotalMessageSizeMetricName string = "grpc.server.call.rcvd_total_compressed_message_size"
|
||||
// ServerCallDurationMetricName is the end-to-end time taken to complete a
|
||||
// call from server transport's perspective.
|
||||
ServerCallDurationMetricName string = "grpc.server.call.duration"
|
||||
)
|
||||
84
stats/opentelemetry/server_tracing.go
Normal file
84
stats/opentelemetry/server_tracing.go
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/stats"
|
||||
otelinternaltracing "google.golang.org/grpc/stats/opentelemetry/internal/tracing"
|
||||
)
|
||||
|
||||
type serverTracingHandler struct {
|
||||
options Options
|
||||
}
|
||||
|
||||
func (h *serverTracingHandler) initializeTraces() {
|
||||
if h.options.TraceOptions.TracerProvider == nil {
|
||||
log.Printf("TracerProvider is not provided in server TraceOptions")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TagRPC implements per RPC attempt context management for traces.
|
||||
func (h *serverTracingHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context {
|
||||
ctx, ri := getOrCreateServerRPCInfo(ctx, info, h.options.MetricsOptions)
|
||||
ctx, _ = h.traceTagRPC(ctx, ri.ai)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// traceTagRPC populates context with new span data using the TextMapPropagator
|
||||
// supplied in trace options and internal itracing.Carrier. It creates a new
|
||||
// incoming carrier which extracts an existing span context (if present) by
|
||||
// deserializing from provided context. If valid span context is extracted, it
|
||||
// is set as parent of the new span otherwise new span remains the root span.
|
||||
// If TextMapPropagator is not provided in the trace options, it returns context
|
||||
// as is.
|
||||
func (h *serverTracingHandler) traceTagRPC(ctx context.Context, ai *attemptInfo) (context.Context, *attemptInfo) {
|
||||
mn := "Recv." + strings.Replace(ai.method, "/", ".", -1)
|
||||
var span trace.Span
|
||||
tracer := h.options.TraceOptions.TracerProvider.Tracer(tracerName, trace.WithInstrumentationVersion(grpc.Version))
|
||||
ctx = h.options.TraceOptions.TextMapPropagator.Extract(ctx, otelinternaltracing.NewIncomingCarrier(ctx))
|
||||
// If the context.Context provided in `ctx` to tracer.Start(), contains a
|
||||
// span then the newly-created Span will be a child of that span,
|
||||
// otherwise it will be a root span.
|
||||
ctx, span = tracer.Start(ctx, mn, trace.WithSpanKind(trace.SpanKindServer))
|
||||
ai.traceSpan = span
|
||||
return ctx, ai
|
||||
}
|
||||
|
||||
// HandleRPC handles per RPC tracing implementation.
|
||||
func (h *serverTracingHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) {
|
||||
ri := serverRPCInfo(ctx)
|
||||
if ri == nil {
|
||||
logger.Error("ctx passed into server side tracing handler trace event handling has no server call data present")
|
||||
return
|
||||
}
|
||||
populateSpan(rs, ri.ai)
|
||||
}
|
||||
|
||||
// TagConn exists to satisfy stats.Handler for tracing.
|
||||
func (h *serverTracingHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
// HandleConn exists to satisfy stats.Handler for tracing.
|
||||
func (h *serverTracingHandler) HandleConn(context.Context, stats.ConnStats) {}
|
||||
74
stats/opentelemetry/trace.go
Normal file
74
stats/opentelemetry/trace.go
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2024 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package opentelemetry
|
||||
|
||||
import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
otelcodes "go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"google.golang.org/grpc/stats"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// populateSpan populates span information based on stats passed in, representing
|
||||
// invariants of the RPC lifecycle. It ends the span, triggering its export.
|
||||
// This function handles attempt spans on the client-side and call spans on the
|
||||
// server-side.
|
||||
func populateSpan(rs stats.RPCStats, ai *attemptInfo) {
|
||||
if ai == nil || ai.traceSpan == nil {
|
||||
// Shouldn't happen, tagRPC call comes before this function gets called
|
||||
// which populates this information.
|
||||
logger.Error("ctx passed into stats handler tracing event handling has no traceSpan present")
|
||||
return
|
||||
}
|
||||
span := ai.traceSpan
|
||||
|
||||
switch rs := rs.(type) {
|
||||
case *stats.DelayedPickComplete:
|
||||
span.AddEvent("Delayed LB pick complete")
|
||||
case *stats.InPayload:
|
||||
// message id - "must be calculated as two different counters starting
|
||||
// from one for sent messages and one for received messages."
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.Int64("sequence-number", int64(ai.countRecvMsg)),
|
||||
attribute.Int64("message-size", int64(rs.Length)),
|
||||
}
|
||||
if rs.CompressedLength != rs.Length {
|
||||
attrs = append(attrs, attribute.Int64("message-size-compressed", int64(rs.CompressedLength)))
|
||||
}
|
||||
span.AddEvent("Inbound message", trace.WithAttributes(attrs...))
|
||||
ai.countRecvMsg++
|
||||
case *stats.OutPayload:
|
||||
attrs := []attribute.KeyValue{
|
||||
attribute.Int64("sequence-number", int64(ai.countSentMsg)),
|
||||
attribute.Int64("message-size", int64(rs.Length)),
|
||||
}
|
||||
if rs.CompressedLength != rs.Length {
|
||||
attrs = append(attrs, attribute.Int64("message-size-compressed", int64(rs.CompressedLength)))
|
||||
}
|
||||
span.AddEvent("Outbound message", trace.WithAttributes(attrs...))
|
||||
ai.countSentMsg++
|
||||
case *stats.End:
|
||||
if rs.Error != nil {
|
||||
s := status.Convert(rs.Error)
|
||||
span.SetStatus(otelcodes.Error, s.Message())
|
||||
} else {
|
||||
span.SetStatus(otelcodes.Ok, "Ok")
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
}
|
||||
321
stats/stats.go
Normal file
321
stats/stats.go
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
*
|
||||
* Copyright 2016 gRPC authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
// Package stats is for collecting and reporting various network and RPC stats.
|
||||
// This package is for monitoring purpose only. All fields are read-only.
|
||||
// All APIs are experimental.
|
||||
package stats // import "google.golang.org/grpc/stats"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
)
|
||||
|
||||
// RPCStats contains stats information about RPCs.
|
||||
type RPCStats interface {
|
||||
isRPCStats()
|
||||
// IsClient returns true if this RPCStats is from client side.
|
||||
IsClient() bool
|
||||
}
|
||||
|
||||
// Begin contains stats for the start of an RPC attempt.
|
||||
//
|
||||
// - Server-side: Triggered after `InHeader`, as headers are processed
|
||||
// before the RPC lifecycle begins.
|
||||
// - Client-side: The first stats event recorded.
|
||||
//
|
||||
// FailFast is only valid if this Begin is from client side.
|
||||
type Begin struct {
|
||||
// Client is true if this Begin is from client side.
|
||||
Client bool
|
||||
// BeginTime is the time when the RPC attempt begins.
|
||||
BeginTime time.Time
|
||||
// FailFast indicates if this RPC is failfast.
|
||||
FailFast bool
|
||||
// IsClientStream indicates whether the RPC is a client streaming RPC.
|
||||
IsClientStream bool
|
||||
// IsServerStream indicates whether the RPC is a server streaming RPC.
|
||||
IsServerStream bool
|
||||
// IsTransparentRetryAttempt indicates whether this attempt was initiated
|
||||
// due to transparently retrying a previous attempt.
|
||||
IsTransparentRetryAttempt bool
|
||||
}
|
||||
|
||||
// IsClient indicates if the stats information is from client side.
|
||||
func (s *Begin) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *Begin) isRPCStats() {}
|
||||
|
||||
// DelayedPickComplete indicates that the RPC is unblocked following a delay in
|
||||
// selecting a connection for the call.
|
||||
type DelayedPickComplete struct{}
|
||||
|
||||
// IsClient indicates DelayedPickComplete is available on the client.
|
||||
func (*DelayedPickComplete) IsClient() bool { return true }
|
||||
|
||||
func (*DelayedPickComplete) isRPCStats() {}
|
||||
|
||||
// PickerUpdated indicates that the RPC is unblocked following a delay in
|
||||
// selecting a connection for the call.
|
||||
//
|
||||
// Deprecated: will be removed in a future release; use DelayedPickComplete
|
||||
// instead.
|
||||
type PickerUpdated = DelayedPickComplete
|
||||
|
||||
// InPayload contains stats about an incoming payload.
|
||||
type InPayload struct {
|
||||
// Client is true if this InPayload is from client side.
|
||||
Client bool
|
||||
// Payload is the payload with original type. This may be modified after
|
||||
// the call to HandleRPC which provides the InPayload returns and must be
|
||||
// copied if needed later.
|
||||
Payload any
|
||||
|
||||
// Length is the size of the uncompressed payload data. Does not include any
|
||||
// framing (gRPC or HTTP/2).
|
||||
Length int
|
||||
// CompressedLength is the size of the compressed payload data. Does not
|
||||
// include any framing (gRPC or HTTP/2). Same as Length if compression not
|
||||
// enabled.
|
||||
CompressedLength int
|
||||
// WireLength is the size of the compressed payload data plus gRPC framing.
|
||||
// Does not include HTTP/2 framing.
|
||||
WireLength int
|
||||
|
||||
// RecvTime is the time when the payload is received.
|
||||
RecvTime time.Time
|
||||
}
|
||||
|
||||
// IsClient indicates if the stats information is from client side.
|
||||
func (s *InPayload) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *InPayload) isRPCStats() {}
|
||||
|
||||
// InHeader contains stats about header reception.
|
||||
//
|
||||
// - Server-side: The first stats event after the RPC request is received.
|
||||
type InHeader struct {
|
||||
// Client is true if this InHeader is from client side.
|
||||
Client bool
|
||||
// WireLength is the wire length of header.
|
||||
WireLength int
|
||||
// Compression is the compression algorithm used for the RPC.
|
||||
Compression string
|
||||
// Header contains the header metadata received.
|
||||
Header metadata.MD
|
||||
|
||||
// The following fields are valid only if Client is false.
|
||||
// FullMethod is the full RPC method string, i.e., /package.service/method.
|
||||
FullMethod string
|
||||
// RemoteAddr is the remote address of the corresponding connection.
|
||||
RemoteAddr net.Addr
|
||||
// LocalAddr is the local address of the corresponding connection.
|
||||
LocalAddr net.Addr
|
||||
}
|
||||
|
||||
// IsClient indicates if the stats information is from client side.
|
||||
func (s *InHeader) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *InHeader) isRPCStats() {}
|
||||
|
||||
// InTrailer contains stats about trailer reception.
|
||||
type InTrailer struct {
|
||||
// Client is true if this InTrailer is from client side.
|
||||
Client bool
|
||||
// WireLength is the wire length of trailer.
|
||||
WireLength int
|
||||
// Trailer contains the trailer metadata received from the server. This
|
||||
// field is only valid if this InTrailer is from the client side.
|
||||
Trailer metadata.MD
|
||||
}
|
||||
|
||||
// IsClient indicates if the stats information is from client side.
|
||||
func (s *InTrailer) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *InTrailer) isRPCStats() {}
|
||||
|
||||
// OutPayload contains stats about an outgoing payload.
|
||||
type OutPayload struct {
|
||||
// Client is true if this OutPayload is from client side.
|
||||
Client bool
|
||||
// Payload is the payload with original type. This may be modified after
|
||||
// the call to HandleRPC which provides the OutPayload returns and must be
|
||||
// copied if needed later.
|
||||
Payload any
|
||||
// Length is the size of the uncompressed payload data. Does not include any
|
||||
// framing (gRPC or HTTP/2).
|
||||
Length int
|
||||
// CompressedLength is the size of the compressed payload data. Does not
|
||||
// include any framing (gRPC or HTTP/2). Same as Length if compression not
|
||||
// enabled.
|
||||
CompressedLength int
|
||||
// WireLength is the size of the compressed payload data plus gRPC framing.
|
||||
// Does not include HTTP/2 framing.
|
||||
WireLength int
|
||||
// SentTime is the time when the payload is sent.
|
||||
SentTime time.Time
|
||||
}
|
||||
|
||||
// IsClient indicates if this stats information is from client side.
|
||||
func (s *OutPayload) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *OutPayload) isRPCStats() {}
|
||||
|
||||
// OutHeader contains stats about header transmission.
|
||||
//
|
||||
// - Client-side: Only occurs after 'Begin', as headers are always the first
|
||||
// thing sent on a stream.
|
||||
type OutHeader struct {
|
||||
// Client is true if this OutHeader is from client side.
|
||||
Client bool
|
||||
// Compression is the compression algorithm used for the RPC.
|
||||
Compression string
|
||||
// Header contains the header metadata sent.
|
||||
Header metadata.MD
|
||||
// Authority is the :authority pseudo-header sent for the RPC.
|
||||
// It is valid only if Client is true.
|
||||
Authority string
|
||||
|
||||
// The following fields are valid only if Client is true.
|
||||
// FullMethod is the full RPC method string, i.e., /package.service/method.
|
||||
FullMethod string
|
||||
// RemoteAddr is the remote address of the corresponding connection.
|
||||
RemoteAddr net.Addr
|
||||
// LocalAddr is the local address of the corresponding connection.
|
||||
LocalAddr net.Addr
|
||||
}
|
||||
|
||||
// IsClient indicates if this stats information is from client side.
|
||||
func (s *OutHeader) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *OutHeader) isRPCStats() {}
|
||||
|
||||
// OutTrailer contains stats about trailer transmission.
|
||||
type OutTrailer struct {
|
||||
// Client is true if this OutTrailer is from client side.
|
||||
Client bool
|
||||
// WireLength is the wire length of trailer.
|
||||
//
|
||||
// Deprecated: This field is never set. The length is not known when this
|
||||
// message is emitted because the trailer fields are compressed with hpack
|
||||
// after that.
|
||||
WireLength int
|
||||
// Trailer contains the trailer metadata sent to the client. This
|
||||
// field is only valid if this OutTrailer is from the server side.
|
||||
Trailer metadata.MD
|
||||
}
|
||||
|
||||
// IsClient indicates if this stats information is from client side.
|
||||
func (s *OutTrailer) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *OutTrailer) isRPCStats() {}
|
||||
|
||||
// End contains stats about RPC completion.
|
||||
type End struct {
|
||||
// Client is true if this End is from client side.
|
||||
Client bool
|
||||
// BeginTime is the time when the RPC began.
|
||||
BeginTime time.Time
|
||||
// EndTime is the time when the RPC ends.
|
||||
EndTime time.Time
|
||||
// Trailer contains the trailer metadata received from the server. This
|
||||
// field is only valid if this End is from the client side.
|
||||
// Deprecated: use Trailer in InTrailer instead.
|
||||
Trailer metadata.MD
|
||||
// Error is the error the RPC ended with. It is an error generated from
|
||||
// status.Status and can be converted back to status.Status using
|
||||
// status.FromError if non-nil.
|
||||
Error error
|
||||
}
|
||||
|
||||
// IsClient indicates if this is from client side.
|
||||
func (s *End) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *End) isRPCStats() {}
|
||||
|
||||
// ConnStats contains stats information about connections.
|
||||
type ConnStats interface {
|
||||
isConnStats()
|
||||
// IsClient returns true if this ConnStats is from client side.
|
||||
IsClient() bool
|
||||
}
|
||||
|
||||
// ConnBegin contains stats about connection establishment.
|
||||
type ConnBegin struct {
|
||||
// Client is true if this ConnBegin is from client side.
|
||||
Client bool
|
||||
}
|
||||
|
||||
// IsClient indicates if this is from client side.
|
||||
func (s *ConnBegin) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *ConnBegin) isConnStats() {}
|
||||
|
||||
// ConnEnd contains stats about connection termination.
|
||||
type ConnEnd struct {
|
||||
// Client is true if this ConnEnd is from client side.
|
||||
Client bool
|
||||
}
|
||||
|
||||
// IsClient indicates if this is from client side.
|
||||
func (s *ConnEnd) IsClient() bool { return s.Client }
|
||||
|
||||
func (s *ConnEnd) isConnStats() {}
|
||||
|
||||
// SetTags attaches stats tagging data to the context, which will be sent in
|
||||
// the outgoing RPC with the header grpc-tags-bin. Subsequent calls to
|
||||
// SetTags will overwrite the values from earlier calls.
|
||||
//
|
||||
// Deprecated: set the `grpc-tags-bin` header in the metadata instead.
|
||||
func SetTags(ctx context.Context, b []byte) context.Context {
|
||||
return metadata.AppendToOutgoingContext(ctx, "grpc-tags-bin", string(b))
|
||||
}
|
||||
|
||||
// Tags returns the tags from the context for the inbound RPC.
|
||||
//
|
||||
// Deprecated: obtain the `grpc-tags-bin` header from metadata instead.
|
||||
func Tags(ctx context.Context) []byte {
|
||||
traceValues := metadata.ValueFromIncomingContext(ctx, "grpc-tags-bin")
|
||||
if len(traceValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(traceValues[len(traceValues)-1])
|
||||
}
|
||||
|
||||
// SetTrace attaches stats tagging data to the context, which will be sent in
|
||||
// the outgoing RPC with the header grpc-trace-bin. Subsequent calls to
|
||||
// SetTrace will overwrite the values from earlier calls.
|
||||
//
|
||||
// Deprecated: set the `grpc-trace-bin` header in the metadata instead.
|
||||
func SetTrace(ctx context.Context, b []byte) context.Context {
|
||||
return metadata.AppendToOutgoingContext(ctx, "grpc-trace-bin", string(b))
|
||||
}
|
||||
|
||||
// Trace returns the trace from the context for the inbound RPC.
|
||||
//
|
||||
// Deprecated: obtain the `grpc-trace-bin` header from metadata instead.
|
||||
func Trace(ctx context.Context) []byte {
|
||||
traceValues := metadata.ValueFromIncomingContext(ctx, "grpc-trace-bin")
|
||||
if len(traceValues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []byte(traceValues[len(traceValues)-1])
|
||||
}
|
||||
1540
stats/stats_test.go
Normal file
1540
stats/stats_test.go
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user