blob: d18045d65b9c6cef302611e7616c37255d60a83e [file] [edit]
// Copyright 2026 Google LLC
//
// 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 storage
import (
"context"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"cloud.google.com/go/iam/apiv1/iampb"
"cloud.google.com/go/storage/internal"
mexporter "github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/resource"
"google.golang.org/api/googleapi"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
customMetricPrefix = "custom.googleapis.com/"
)
// clientMetrics contains the OpenTelemetry metric instruments to record client-side metrics.
type clientMetrics struct {
provider *sdkmetric.MeterProvider
rpcClientCallDuration metric.Float64Histogram
httpClientRequestDuration metric.Float64Histogram
duration metric.Float64Histogram
operations metric.Int64Counter
attempts metric.Int64Counter
requestBodySize metric.Int64Histogram
responseBodySize metric.Int64Histogram
ttfb metric.Float64Histogram
errors metric.Int64Counter
}
func formatMetricWithPrefix(m metricdata.Metrics, prefix string) string {
return prefix + strings.ReplaceAll(string(m.Name), ".", "/")
}
// isOtelMetricsEnabled checks if Otel metrics are enabled.
// The environment variable GCP_STORAGE_GO_ENABLE_OTEL_METRICS takes precedence.
func isOtelMetricsEnabled(config *storageConfig) bool {
if config.disableClientMetrics {
return false
}
if valStr, present := os.LookupEnv("GCP_STORAGE_GO_ENABLE_OTEL_METRICS"); present {
v, err := strconv.ParseBool(valStr)
if err == nil {
return v
}
}
return config.enableOtelMetrics
}
// newMetricsGCMExporter creates a Google Cloud Monitoring exporter.
func newMetricsGCMExporter(ctx context.Context, projectID string) (sdkmetric.Exporter, error) {
exporter, err := mexporter.New(
mexporter.WithProjectID(projectID),
mexporter.WithMetricDescriptorTypeFormatter(func(m metricdata.Metrics) string {
return formatMetricWithPrefix(m, customMetricPrefix)
}),
mexporter.WithCreateServiceTimeSeries(),
)
if err != nil {
return nil, fmt.Errorf("storage: creating GCM exporter: %w", err)
}
return exporter, nil
}
// initMetrics initializes clientMetrics with a meter provider and registered exporter.
func initMetrics(ctx context.Context, projectID string, config *storageConfig) (*clientMetrics, func(), error) {
var provider *sdkmetric.MeterProvider
var ownProvider bool
if config.meterProvider != nil {
provider = config.meterProvider
} else {
var exporter sdkmetric.Exporter
var err error
if config.metricExporter != nil {
exporter = *config.metricExporter
} else {
exporter, err = newMetricsGCMExporter(ctx, projectID)
if err != nil {
return nil, nil, err
}
}
interval := time.Minute
if config.metricInterval > 0 {
interval = config.metricInterval
}
reader := sdkmetric.NewPeriodicReader(&exporterLogSuppressor{Exporter: exporter}, sdkmetric.WithInterval(interval))
// Static common attributes are defined as Resource Attributes.
res, err := resource.New(ctx,
resource.WithAttributes(
attribute.String("gcp.client.version", internal.Version),
attribute.String("gcp.client.service", "storage"),
attribute.String("gcp.client.repo", "googleapis/google-cloud-go"),
attribute.String("gcp.client.artifact", "cloud.google.com/go/storage"),
),
)
if err != nil {
return nil, nil, fmt.Errorf("storage: creating metrics resource: %w", err)
}
provider = sdkmetric.NewMeterProvider(
sdkmetric.WithReader(reader),
sdkmetric.WithResource(res),
sdkmetric.WithView(
sdkmetric.NewView(
sdkmetric.Instrument{Name: "rpc.client.call.duration", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: latencyHistogramBoundaries()}},
),
sdkmetric.NewView(
sdkmetric.Instrument{Name: "http.client.request.duration", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: latencyHistogramBoundaries()}},
),
sdkmetric.NewView(
sdkmetric.Instrument{Name: "gcp.client.request.duration", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: latencyHistogramBoundaries()}},
),
sdkmetric.NewView(
sdkmetric.Instrument{Name: "gcp.storage.client.operation.ttfb", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: latencyHistogramBoundaries()}},
),
sdkmetric.NewView(
sdkmetric.Instrument{Name: "gcp.storage.client.request.body.size", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: sizeHistogramBoundaries()}},
),
sdkmetric.NewView(
sdkmetric.Instrument{Name: "gcp.storage.client.response.body.size", Kind: sdkmetric.InstrumentKindHistogram},
sdkmetric.Stream{Aggregation: sdkmetric.AggregationExplicitBucketHistogram{Boundaries: sizeHistogramBoundaries()}},
),
),
)
ownProvider = true
}
meter := provider.Meter("cloud.google.com/go/storage")
rpcDuration, err := meter.Float64Histogram(
"rpc.client.call.duration",
metric.WithDescription("Duration of one gRPC request. Retries not included (Otel)"),
metric.WithUnit("s"),
)
if err != nil {
return nil, nil, err
}
httpDuration, err := meter.Float64Histogram(
"http.client.request.duration",
metric.WithDescription("Duration of one HTTP client request. Retried not included (Otel)"),
metric.WithUnit("s"),
)
if err != nil {
return nil, nil, err
}
duration, err := meter.Float64Histogram(
"gcp.client.request.duration",
metric.WithDescription("Latency of a client operation"),
metric.WithUnit("s"),
)
if err != nil {
return nil, nil, err
}
operations, err := meter.Int64Counter(
"gcp.storage.client.operations",
metric.WithDescription("Number of GCS client operations"),
metric.WithUnit("1"),
)
if err != nil {
return nil, nil, err
}
attempts, err := meter.Int64Counter(
"gcp.storage.client.attempts",
metric.WithDescription("Number of GCS client attempts"),
metric.WithUnit("1"),
)
if err != nil {
return nil, nil, err
}
requestBodySize, err := meter.Int64Histogram(
"gcp.storage.client.request.body.size",
metric.WithDescription("Size of GCS client request body"),
metric.WithUnit("By"),
)
if err != nil {
return nil, nil, err
}
responseBodySize, err := meter.Int64Histogram(
"gcp.storage.client.response.body.size",
metric.WithDescription("Size of GCS client response body"),
metric.WithUnit("By"),
)
if err != nil {
return nil, nil, err
}
ttfb, err := meter.Float64Histogram(
"gcp.storage.client.operation.ttfb",
metric.WithDescription("Time to first byte of GCS client operations"),
metric.WithUnit("s"),
)
if err != nil {
return nil, nil, err
}
errors, err := meter.Int64Counter(
"gcp.storage.client.errors",
metric.WithDescription("Number of GCS client errors"),
metric.WithUnit("1"),
)
if err != nil {
return nil, nil, err
}
cm := &clientMetrics{
provider: provider,
rpcClientCallDuration: rpcDuration,
httpClientRequestDuration: httpDuration,
duration: duration,
operations: operations,
attempts: attempts,
requestBodySize: requestBodySize,
responseBodySize: responseBodySize,
ttfb: ttfb,
errors: errors,
}
var cleanup func()
if ownProvider {
cleanup = func() {
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
provider.Shutdown(shutdownCtx)
}
}
return cm, cleanup, nil
}
// grpcCodeToString maps a gRPC status code to its screaming-snake-case protocol name.
func grpcCodeToString(code codes.Code) string {
switch code {
case codes.OK:
return "OK"
case codes.Canceled:
return "CANCELLED"
case codes.Unknown:
return "UNKNOWN"
case codes.InvalidArgument:
return "INVALID_ARGUMENT"
case codes.DeadlineExceeded:
return "DEADLINE_EXCEEDED"
case codes.NotFound:
return "NOT_FOUND"
case codes.AlreadyExists:
return "ALREADY_EXISTS"
case codes.PermissionDenied:
return "PERMISSION_DENIED"
case codes.ResourceExhausted:
return "RESOURCE_EXHAUSTED"
case codes.FailedPrecondition:
return "FAILED_PRECONDITION"
case codes.Aborted:
return "ABORTED"
case codes.OutOfRange:
return "OUT_OF_RANGE"
case codes.Unimplemented:
return "UNIMPLEMENTED"
case codes.Internal:
return "INTERNAL"
case codes.Unavailable:
return "UNAVAILABLE"
case codes.DataLoss:
return "DATA_LOSS"
case codes.Unauthenticated:
return "UNAUTHENTICATED"
default:
return "UNKNOWN"
}
}
// computeErrorType maps the request result to the standard error.type values.
func computeErrorType(err error, isHTTP bool, statusCode int64) string {
if err == nil {
if isHTTP && statusCode >= 400 {
return mapHTTPStatusCode(int(statusCode))
}
return "OK"
}
if err == io.EOF {
return "OK"
}
errStr := strings.ToLower(err.Error())
if err == context.Canceled || strings.Contains(errStr, "context canceled") {
return "CANCELLED"
}
if err == context.DeadlineExceeded || strings.Contains(errStr, "deadline exceeded") || strings.Contains(errStr, "timeout") {
return "TIMEOUT"
}
if strings.Contains(errStr, "checksum") || strings.Contains(errStr, "mismatch") {
return "CHECKSUM_MISMATCH"
}
if strings.Contains(errStr, "auth") || strings.Contains(errStr, "credentials") || strings.Contains(errStr, "token") || strings.Contains(errStr, "key") {
return "AUTHENTICATION_ERROR"
}
if strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "dial tcp") || strings.Contains(errStr, "no such host") || strings.Contains(errStr, "broken pipe") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "eof") {
return "CONNECTIVITY"
}
if !isHTTP {
if st, ok := status.FromError(err); ok && st.Code() != codes.OK {
return grpcCodeToString(st.Code())
}
}
if isHTTP {
var apiErr *googleapi.Error
if errors.As(err, &apiErr) {
return mapHTTPStatusCode(apiErr.Code)
}
if statusCode >= 400 {
return mapHTTPStatusCode(int(statusCode))
}
}
return "UNKNOWN"
}
// mapHTTPStatusCode converts an HTTP status code to a canonical API error string.
// If there is no direct mapping, it returns the numeric string.
func mapHTTPStatusCode(code int) string {
switch code {
case 400:
return "INVALID_ARGUMENT"
case 401:
return "UNAUTHENTICATED"
case 403:
return "PERMISSION_DENIED"
case 404:
return "NOT_FOUND"
case 409:
return "ABORTED"
case 416:
return "OUT_OF_RANGE"
case 429:
return "RESOURCE_EXHAUSTED"
case 499:
return "CANCELLED"
case 500:
return "INTERNAL"
case 501:
return "UNIMPLEMENTED"
case 503:
return "UNAVAILABLE"
case 504:
return "DEADLINE_EXCEEDED"
default:
return strconv.Itoa(code)
}
}
func (cm *clientMetrics) recordRPC(ctx context.Context, method, target string, duration float64, err error) {
statusCode := int64(codes.OK)
if err != nil && err != io.EOF {
statusCode = int64(status.Code(err))
}
service := "google.storage.v2.Storage"
methodName := method
if strings.HasPrefix(method, "/") {
parts := strings.Split(strings.TrimPrefix(method, "/"), "/")
if len(parts) >= 2 {
service = parts[0]
methodName = parts[1]
}
}
errorType := computeErrorType(err, false, statusCode)
attrs := []attribute.KeyValue{
attribute.String("rpc.system.name", "grpc"),
attribute.String("rpc.service", service),
attribute.String("rpc.method", methodName),
attribute.Int64("rpc.grpc.status_code", statusCode),
attribute.Int64("rpc.response.status_code", statusCode),
attribute.String("server.address", stripPort(target)),
attribute.String("error.type", errorType),
}
cm.rpcClientCallDuration.Record(ctx, duration, metric.WithAttributes(attrs...))
// Record standard attempt metric: gcp.storage.client.attempts.
state := metricsStateFromContext(ctx)
logicalMethod := methodName
if state != nil {
logicalMethod = state.method
}
attemptAttrs := []attribute.KeyValue{
attribute.String("rpc.method", logicalMethod),
attribute.Int64("rpc.grpc.status_code", statusCode),
attribute.String("error.type", errorType),
}
cm.attempts.Add(ctx, 1, metric.WithAttributes(attemptAttrs...))
// Record standard error metric: gcp.storage.client.errors.
if err != nil && err != io.EOF {
errorAttrs := []attribute.KeyValue{
attribute.String("rpc.method", logicalMethod),
attribute.String("error.type", errorType),
attribute.String("gcp.errors.domain", "storage.googleapis.com"),
}
cm.errors.Add(ctx, 1, metric.WithAttributes(errorAttrs...))
}
// For unary calls, record TTFB equal to the total attempt latency.
isStreaming := methodName == "ReadObject" || methodName == "WriteObject" || methodName == "BidiReadObject" || methodName == "BidiWriteObject"
if !isStreaming {
cm.ttfb.Record(ctx, duration, metric.WithAttributes(attribute.String("rpc.method", logicalMethod)))
}
}
func (cm *clientMetrics) recordHTTP(ctx context.Context, req *http.Request, resp *http.Response, duration float64, err error) {
statusCode := int64(0)
if resp != nil {
statusCode = int64(resp.StatusCode)
}
urlTemplate := computeURLTemplate(req.URL.Path, req.URL.Host)
errorType := computeErrorType(err, true, statusCode)
attrs := []attribute.KeyValue{
attribute.String("rpc.system.name", "http"),
attribute.String("http.request.method", req.Method),
attribute.String("url.template", urlTemplate),
attribute.Int64("http.response.status_code", statusCode),
attribute.Int64("rpc.response.status_code", statusCode),
attribute.String("server.address", stripPort(req.URL.Host)),
attribute.String("error.type", errorType),
}
cm.httpClientRequestDuration.Record(ctx, duration, metric.WithAttributes(attrs...))
}
// computeURLTemplate extracts a parameterized template path for a given GCS HTTP request URL path.
func computeURLTemplate(path, host string) string {
// Check for XML host-style: {bucket}.storage.googleapis.com.
if strings.HasSuffix(host, ".storage.googleapis.com") && host != "storage.googleapis.com" {
if path == "/" || path == "" {
return "/"
}
return "/{object}"
}
// Check for XML path-style or JSON API.
if !strings.HasPrefix(path, "/storage/") && !strings.HasPrefix(path, "/upload/") && !strings.HasPrefix(path, "/batch") {
p := strings.TrimPrefix(path, "/")
parts := strings.SplitN(p, "/", 2)
if len(parts) == 1 {
if parts[0] == "" {
return "/"
}
return "/{bucket}"
}
return "/{bucket}/{object}"
}
// JSON API: /storage/v1/b/bucket-name/o/object-name etc.
bIdx := strings.Index(path, "/b/")
if bIdx == -1 {
return path
}
prefix := path[:bIdx+3]
rest := path[bIdx+3:]
parts := strings.SplitN(rest, "/", 2)
if len(parts) == 1 {
return prefix + "{bucket}"
}
oRest := parts[1]
if oRest == "o" {
return prefix + "{bucket}/o"
}
if strings.HasPrefix(oRest, "o/") {
return prefix + "{bucket}/o/{object}"
}
return prefix + "{bucket}/" + oRest
}
func stripPort(host string) string {
if h, _, err := net.SplitHostPort(host); err == nil {
return h
}
return host
}
// metricsRoundTripper is an http.RoundTripper that wraps an underlying transport.
type metricsRoundTripper struct {
base http.RoundTripper
metrics *clientMetrics
}
func (rt *metricsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
startTime := time.Now()
resp, err := rt.base.RoundTrip(req)
state := metricsStateFromContext(req.Context())
var logicalMethod string
if state != nil {
logicalMethod = state.method
} else {
logicalMethod = "Unknown"
}
statusCode := int64(0)
if resp != nil {
statusCode = int64(resp.StatusCode)
}
errorType := computeErrorType(err, true, statusCode)
if rt.metrics != nil {
// Record attempt.
attemptAttrs := []attribute.KeyValue{
attribute.String("rpc.method", logicalMethod),
attribute.Int64("http.response.status_code", statusCode),
attribute.String("error.type", errorType),
}
rt.metrics.attempts.Add(req.Context(), 1, metric.WithAttributes(attemptAttrs...))
// Record error if failed.
if err != nil || (resp != nil && resp.StatusCode >= 400) {
errorAttrs := []attribute.KeyValue{
attribute.String("rpc.method", logicalMethod),
attribute.String("error.type", errorType),
attribute.String("gcp.errors.domain", "storage.googleapis.com"),
}
rt.metrics.errors.Add(req.Context(), 1, metric.WithAttributes(errorAttrs...))
}
// Record TTFB.
isDownload := req.Method == "GET" && req.URL.Query().Get("alt") == "media"
isResumableInit := req.Method == "POST" && strings.Contains(req.URL.Path, "/upload/") && req.URL.Query().Get("uploadType") == "resumable"
if !isDownload || isResumableInit {
duration := time.Since(startTime).Seconds()
rt.metrics.ttfb.Record(req.Context(), duration, metric.WithAttributes(attribute.String("rpc.method", logicalMethod)))
}
}
if err != nil {
if rt.metrics != nil {
duration := time.Since(startTime).Seconds()
rt.metrics.recordHTTP(req.Context(), req, nil, duration, err)
}
return nil, err
}
if resp.Body != nil {
resp.Body = &wrappedResponseBody{
ReadCloser: resp.Body,
startTime: startTime,
req: req,
resp: resp,
metrics: rt.metrics,
isDownload: req.Method == "GET" && req.URL.Query().Get("alt") == "media",
}
} else {
if rt.metrics != nil {
duration := time.Since(startTime).Seconds()
rt.metrics.recordHTTP(req.Context(), req, resp, duration, nil)
}
}
return resp, nil
}
type wrappedResponseBody struct {
io.ReadCloser
startTime time.Time
req *http.Request
resp *http.Response
metrics *clientMetrics
recorded atomic.Bool
isDownload bool
firstRead atomic.Bool
}
func (w *wrappedResponseBody) Read(p []byte) (n int, err error) {
if w.isDownload && w.metrics != nil && w.firstRead.CompareAndSwap(false, true) {
duration := time.Since(w.startTime).Seconds()
state := metricsStateFromContext(w.req.Context())
logicalMethod := "ReadObject"
if state != nil {
logicalMethod = state.method
}
w.metrics.ttfb.Record(w.req.Context(), duration, metric.WithAttributes(attribute.String("rpc.method", logicalMethod)))
}
n, err = w.ReadCloser.Read(p)
if err != nil {
w.record(err)
}
return n, err
}
func (w *wrappedResponseBody) Close() error {
err := w.ReadCloser.Close()
w.record(err)
return err
}
func (w *wrappedResponseBody) record(err error) {
if w.recorded.CompareAndSwap(false, true) {
duration := time.Since(w.startTime).Seconds()
w.metrics.recordHTTP(w.req.Context(), w.req, w.resp, duration, err)
}
}
// metricsInterceptors returns gRPC client interceptors.
func metricsInterceptors(cm *clientMetrics) (grpc.UnaryClientInterceptor, grpc.StreamClientInterceptor) {
unary := func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
startTime := time.Now()
err := invoker(ctx, method, req, reply, cc, opts...)
duration := time.Since(startTime).Seconds()
target := ""
if cc != nil {
target = cc.Target()
}
cm.recordRPC(ctx, method, target, duration, err)
return err
}
stream := func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
startTime := time.Now()
clientStream, err := streamer(ctx, desc, cc, method, opts...)
target := ""
if cc != nil {
target = cc.Target()
}
if err != nil {
duration := time.Since(startTime).Seconds()
cm.recordRPC(ctx, method, target, duration, err)
return nil, err
}
return &wrappedClientStream{
ClientStream: clientStream,
startTime: startTime,
method: method,
target: target,
metrics: cm,
ctx: ctx,
serverStreams: desc.ServerStreams,
clientStreams: desc.ClientStreams,
}, nil
}
return unary, stream
}
type wrappedClientStream struct {
grpc.ClientStream
startTime time.Time
method string
target string
metrics *clientMetrics
ctx context.Context
recorded atomic.Bool
serverStreams bool
clientStreams bool
recordedTTFB atomic.Bool
}
func (w *wrappedClientStream) RecvMsg(m interface{}) error {
err := w.ClientStream.RecvMsg(m)
if err == nil {
w.recordTTFB(m)
}
// For client-streaming streams (like WriteObject), the single successful RecvMsg call
// returns the response and nil error, which marks the completion of the stream.
isClientStreaming := !w.serverStreams && w.clientStreams
if err != nil || isClientStreaming {
w.record(err)
}
return err
}
func (w *wrappedClientStream) SendMsg(m interface{}) error {
err := w.ClientStream.SendMsg(m)
if err != nil {
w.record(err)
}
return err
}
func (w *wrappedClientStream) record(err error) {
if w.recorded.CompareAndSwap(false, true) {
duration := time.Since(w.startTime).Seconds()
w.metrics.recordRPC(w.ctx, w.method, w.target, duration, err)
}
}
func (w *wrappedClientStream) recordTTFB(m interface{}) {
if w.recordedTTFB.Load() {
return
}
methodName := w.method
if strings.HasPrefix(methodName, "/") {
parts := strings.Split(strings.TrimPrefix(methodName, "/"), "/")
if len(parts) >= 2 {
methodName = parts[1]
}
}
// The first response from the server, whether it contains metadata,
// persisted size, or actual content, indicates TTFB.
if w.recordedTTFB.CompareAndSwap(false, true) {
duration := time.Since(w.startTime).Seconds()
state := metricsStateFromContext(w.ctx)
logicalMethod := methodName
if state != nil {
logicalMethod = state.method
}
w.metrics.ttfb.Record(w.ctx, duration, metric.WithAttributes(attribute.String("rpc.method", logicalMethod)))
}
}
type metricsKey struct{}
type metricsState struct {
method string
startTime time.Time
metrics *clientMetrics
isHTTP bool
ttfbRecorded atomic.Bool
ttfbStart time.Time
record func(error)
}
func contextWithMetricsState(ctx context.Context, state *metricsState) context.Context {
return context.WithValue(ctx, metricsKey{}, state)
}
func metricsStateFromContext(ctx context.Context) *metricsState {
if ctx == nil {
return nil
}
if state, ok := ctx.Value(metricsKey{}).(*metricsState); ok {
return state
}
return nil
}
func contextWithoutMetrics(ctx context.Context) context.Context {
if ctx == nil {
return nil
}
return context.WithValue(ctx, metricsKey{}, (*metricsState)(nil))
}
func (cm *clientMetrics) startOperation(ctx context.Context, method string, isHTTP bool) (context.Context, func(error)) {
if cm == nil {
return ctx, func(error) {}
}
state := &metricsState{
method: method,
startTime: time.Now(),
metrics: cm,
isHTTP: isHTTP,
}
state.ttfbStart = state.startTime
var recordOnce sync.Once
record := func(err error) {
recordOnce.Do(func() {
duration := time.Since(state.startTime).Seconds()
statusStr := "OK"
if err != nil && err != io.EOF {
statusStr = "Error"
}
errorType := computeErrorType(err, isHTTP, 0)
attrs := []attribute.KeyValue{
attribute.String("rpc.method", method),
attribute.String("status", statusStr),
attribute.String("error.type", errorType),
}
opts := metric.WithAttributes(attrs...)
cm.duration.Record(ctx, duration, opts)
cm.operations.Add(ctx, 1, opts)
})
}
state.record = record
ctx = contextWithMetricsState(ctx, state)
return ctx, record
}
// startMetricsOp starts a client operation if OpenTelemetry metrics are enabled in ctx.
// It returns the updated context containing metrics state and a recording closure.
func startMetricsOp(ctx context.Context, method string, isHTTP bool) (context.Context, func(error)) {
if state := metricsStateFromContext(ctx); state != nil && state.metrics != nil {
return state.metrics.startOperation(ctx, method, isHTTP)
}
return ctx, func(error) {}
}
// initClientMetrics initializes OpenTelemetry client metrics if enabled in config.
// It returns the metrics instance and its cleanup function, or nil if disabled or upon error.
func initClientMetrics(ctx context.Context, project string, config *storageConfig) (*clientMetrics, func()) {
if !isOtelMetricsEnabled(config) {
return nil, nil
}
cm, cleanup, err := initMetrics(ctx, project, config)
if err != nil {
log.Printf("Failed to enable metrics: %v", err)
return nil, nil
}
return cm, cleanup
}
// metricsStorageClient wraps a storageClient and records client-level metrics.
type metricsStorageClient struct {
storageClient
metrics *clientMetrics
isHTTP bool
}
func (mc *metricsStorageClient) GetServiceAccount(ctx context.Context, project string, opts ...storageOption) (string, error) {
ctx, record := mc.metrics.startOperation(ctx, "GetServiceAccount", mc.isHTTP)
res, err := mc.storageClient.GetServiceAccount(ctx, project, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) CreateBucket(ctx context.Context, project, bucket string, attrs *BucketAttrs, enableObjectRetention *bool, opts ...storageOption) (*BucketAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "CreateBucket", mc.isHTTP)
res, err := mc.storageClient.CreateBucket(ctx, project, bucket, attrs, enableObjectRetention, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) ListBuckets(ctx context.Context, project string, opts ...storageOption) *BucketIterator {
ctx, _ = mc.metrics.startOperation(ctx, "ListBuckets", mc.isHTTP)
return mc.storageClient.ListBuckets(ctx, project, opts...)
}
func (mc *metricsStorageClient) DeleteBucket(ctx context.Context, bucket string, conds *BucketConditions, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteBucket", mc.isHTTP)
err := mc.storageClient.DeleteBucket(ctx, bucket, conds, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) GetBucket(ctx context.Context, bucket string, conds *BucketConditions, opts ...storageOption) (*BucketAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "GetBucket", mc.isHTTP)
res, err := mc.storageClient.GetBucket(ctx, bucket, conds, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) UpdateBucket(ctx context.Context, bucket string, uattrs *BucketAttrsToUpdate, conds *BucketConditions, opts ...storageOption) (*BucketAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "UpdateBucket", mc.isHTTP)
res, err := mc.storageClient.UpdateBucket(ctx, bucket, uattrs, conds, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) LockBucketRetentionPolicy(ctx context.Context, bucket string, conds *BucketConditions, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "LockBucketRetentionPolicy", mc.isHTTP)
err := mc.storageClient.LockBucketRetentionPolicy(ctx, bucket, conds, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) ListObjects(ctx context.Context, bucket string, q *Query, opts ...storageOption) *ObjectIterator {
ctx, _ = mc.metrics.startOperation(ctx, "ListObjects", mc.isHTTP)
return mc.storageClient.ListObjects(ctx, bucket, q, opts...)
}
func (mc *metricsStorageClient) DeleteObject(ctx context.Context, bucket, object string, gen int64, conds *Conditions, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteObject", mc.isHTTP)
err := mc.storageClient.DeleteObject(ctx, bucket, object, gen, conds, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) GetObject(ctx context.Context, params *getObjectParams, opts ...storageOption) (*ObjectAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "GetObject", mc.isHTTP)
res, err := mc.storageClient.GetObject(ctx, params, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) UpdateObject(ctx context.Context, params *updateObjectParams, opts ...storageOption) (*ObjectAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "UpdateObject", mc.isHTTP)
res, err := mc.storageClient.UpdateObject(ctx, params, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) RestoreObject(ctx context.Context, params *restoreObjectParams, opts ...storageOption) (*ObjectAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "RestoreObject", mc.isHTTP)
res, err := mc.storageClient.RestoreObject(ctx, params, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) MoveObject(ctx context.Context, params *moveObjectParams, opts ...storageOption) (*ObjectAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "MoveObject", mc.isHTTP)
res, err := mc.storageClient.MoveObject(ctx, params, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) ComposeObject(ctx context.Context, req *composeObjectRequest, opts ...storageOption) (*ObjectAttrs, error) {
ctx, record := mc.metrics.startOperation(ctx, "ComposeObject", mc.isHTTP)
res, err := mc.storageClient.ComposeObject(ctx, req, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) RewriteObject(ctx context.Context, req *rewriteObjectRequest, opts ...storageOption) (*rewriteObjectResponse, error) {
ctx, record := mc.metrics.startOperation(ctx, "RewriteObject", mc.isHTTP)
res, err := mc.storageClient.RewriteObject(ctx, req, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) NewRangeReader(ctx context.Context, params *newRangeReaderParams, opts ...storageOption) (*Reader, error) {
ctx, record := mc.metrics.startOperation(ctx, "ReadObject", mc.isHTTP)
r, err := mc.storageClient.NewRangeReader(ctx, params, opts...)
if err != nil {
record(err)
return nil, err
}
if state := metricsStateFromContext(ctx); state != nil {
r.metricsState = state
}
return r, nil
}
func (mc *metricsStorageClient) OpenWriter(params *openWriterParams, opts ...storageOption) (internalWriter, error) {
ctx, _ := mc.metrics.startOperation(params.ctx, "WriteObject", mc.isHTTP)
params.ctx = ctx
return mc.storageClient.OpenWriter(params, opts...)
}
func (mc *metricsStorageClient) NewMultiRangeDownloader(ctx context.Context, params *newMultiRangeDownloaderParams, opts ...storageOption) (*MultiRangeDownloader, error) {
ctx, _ = mc.metrics.startOperation(ctx, "ReadObject", mc.isHTTP)
return mc.storageClient.NewMultiRangeDownloader(ctx, params, opts...)
}
func (mc *metricsStorageClient) GetIamPolicy(ctx context.Context, resource string, version int32, opts ...storageOption) (*iampb.Policy, error) {
ctx, record := mc.metrics.startOperation(ctx, "GetIamPolicy", mc.isHTTP)
res, err := mc.storageClient.GetIamPolicy(ctx, resource, version, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) SetIamPolicy(ctx context.Context, resource string, policy *iampb.Policy, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "SetIamPolicy", mc.isHTTP)
err := mc.storageClient.SetIamPolicy(ctx, resource, policy, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) TestIamPermissions(ctx context.Context, resource string, permissions []string, opts ...storageOption) ([]string, error) {
ctx, record := mc.metrics.startOperation(ctx, "TestIamPermissions", mc.isHTTP)
res, err := mc.storageClient.TestIamPermissions(ctx, resource, permissions, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) GetHMACKey(ctx context.Context, project, accessID string, opts ...storageOption) (*HMACKey, error) {
ctx, record := mc.metrics.startOperation(ctx, "GetHMACKey", mc.isHTTP)
res, err := mc.storageClient.GetHMACKey(ctx, project, accessID, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) ListHMACKeys(ctx context.Context, project string, serviceAccountEmail string, showDeletedKeys bool, opts ...storageOption) *HMACKeysIterator {
ctx, _ = mc.metrics.startOperation(ctx, "ListHMACKeys", mc.isHTTP)
return mc.storageClient.ListHMACKeys(ctx, project, serviceAccountEmail, showDeletedKeys, opts...)
}
func (mc *metricsStorageClient) UpdateHMACKey(ctx context.Context, project, serviceAccountEmail, accessID string, attrs *HMACKeyAttrsToUpdate, opts ...storageOption) (*HMACKey, error) {
ctx, record := mc.metrics.startOperation(ctx, "UpdateHMACKey", mc.isHTTP)
res, err := mc.storageClient.UpdateHMACKey(ctx, project, serviceAccountEmail, accessID, attrs, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) CreateHMACKey(ctx context.Context, project, serviceAccountEmail string, opts ...storageOption) (*HMACKey, error) {
ctx, record := mc.metrics.startOperation(ctx, "CreateHMACKey", mc.isHTTP)
res, err := mc.storageClient.CreateHMACKey(ctx, project, serviceAccountEmail, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) DeleteHMACKey(ctx context.Context, project, accessID string, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteHMACKey", mc.isHTTP)
err := mc.storageClient.DeleteHMACKey(ctx, project, accessID, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) ListNotifications(ctx context.Context, bucket string, opts ...storageOption) (map[string]*Notification, error) {
ctx, record := mc.metrics.startOperation(ctx, "ListNotifications", mc.isHTTP)
res, err := mc.storageClient.ListNotifications(ctx, bucket, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) CreateNotification(ctx context.Context, bucket string, n *Notification, opts ...storageOption) (*Notification, error) {
ctx, record := mc.metrics.startOperation(ctx, "CreateNotification", mc.isHTTP)
res, err := mc.storageClient.CreateNotification(ctx, bucket, n, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) DeleteNotification(ctx context.Context, bucket string, id string, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteNotification", mc.isHTTP)
err := mc.storageClient.DeleteNotification(ctx, bucket, id, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) DeleteDefaultObjectACL(ctx context.Context, bucket string, entity ACLEntity, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteDefaultObjectACL", mc.isHTTP)
err := mc.storageClient.DeleteDefaultObjectACL(ctx, bucket, entity, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) ListDefaultObjectACLs(ctx context.Context, bucket string, opts ...storageOption) ([]ACLRule, error) {
ctx, record := mc.metrics.startOperation(ctx, "ListDefaultObjectACLs", mc.isHTTP)
res, err := mc.storageClient.ListDefaultObjectACLs(ctx, bucket, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) UpdateDefaultObjectACL(ctx context.Context, bucket string, entity ACLEntity, role ACLRole, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "UpdateDefaultObjectACL", mc.isHTTP)
err := mc.storageClient.UpdateDefaultObjectACL(ctx, bucket, entity, role, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) DeleteBucketACL(ctx context.Context, bucket string, entity ACLEntity, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteBucketACL", mc.isHTTP)
err := mc.storageClient.DeleteBucketACL(ctx, bucket, entity, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) ListBucketACLs(ctx context.Context, bucket string, opts ...storageOption) ([]ACLRule, error) {
ctx, record := mc.metrics.startOperation(ctx, "ListBucketACLs", mc.isHTTP)
res, err := mc.storageClient.ListBucketACLs(ctx, bucket, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) UpdateBucketACL(ctx context.Context, bucket string, entity ACLEntity, role ACLRole, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "UpdateBucketACL", mc.isHTTP)
err := mc.storageClient.UpdateBucketACL(ctx, bucket, entity, role, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) DeleteObjectACL(ctx context.Context, bucket, object string, entity ACLEntity, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "DeleteObjectACL", mc.isHTTP)
err := mc.storageClient.DeleteObjectACL(ctx, bucket, object, entity, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) ListObjectACLs(ctx context.Context, bucket, object string, opts ...storageOption) ([]ACLRule, error) {
ctx, record := mc.metrics.startOperation(ctx, "ListObjectACLs", mc.isHTTP)
res, err := mc.storageClient.ListObjectACLs(ctx, bucket, object, opts...)
record(err)
return res, err
}
func (mc *metricsStorageClient) UpdateObjectACL(ctx context.Context, bucket, object string, entity ACLEntity, role ACLRole, opts ...storageOption) error {
ctx, record := mc.metrics.startOperation(ctx, "UpdateObjectACL", mc.isHTTP)
err := mc.storageClient.UpdateObjectACL(ctx, bucket, object, entity, role, opts...)
record(err)
return err
}
func (mc *metricsStorageClient) Close() error {
return mc.storageClient.Close()
}
func (mc *metricsStorageClient) fetchBucketMetadata(ctx context.Context, bucket string) (string, string, error) {
return mc.storageClient.fetchBucketMetadata(ctx, bucket)
}