snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
13
internal/telemetry/detectors.go
Normal file
13
internal/telemetry/detectors.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
)
|
||||
|
||||
// resourceDetectors returns extra resource detectors to attach to the
|
||||
// telemetry resource based on the OpenTelemetry configuration.
|
||||
func resourceDetectors(cfg configtypes.OpenTelemetry) ([]resource.Detector, error) {
|
||||
return nil, nil
|
||||
}
|
||||
164
internal/telemetry/provider.go
Normal file
164
internal/telemetry/provider.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/centrifugal/centrifugo/v6/internal/build"
|
||||
"github.com/centrifugal/centrifugo/v6/internal/configtypes"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
"go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/oauth"
|
||||
)
|
||||
|
||||
// googleCloudAuthScope is the OAuth2 scope used when authenticating to Google
|
||||
// Cloud's OTLP endpoint (telemetry.googleapis.com) with Application Default
|
||||
// Credentials.
|
||||
const googleCloudAuthScope = "https://www.googleapis.com/auth/cloud-platform"
|
||||
|
||||
// newResource builds the OTel resource attached to telemetry providers.
|
||||
// instanceID (Centrifugo node ID) becomes the default service.instance.id —
|
||||
// backends that require points of a time series to arrive in order (notably
|
||||
// Google Cloud Managed Service for Prometheus) reject or collapse metrics when
|
||||
// several instances report under the same identity, so every process must
|
||||
// carry a distinct one. Optional detectors run first, so both Centrifugo
|
||||
// defaults and environment values override what they detect.
|
||||
// OTEL_RESOURCE_ATTRIBUTES / OTEL_SERVICE_NAME are merged with precedence over
|
||||
// Centrifugo defaults; the SDK's WithResource merges environment attributes
|
||||
// too, but with the opposite precedence (explicitly passed attributes win
|
||||
// there), so the env override must happen here.
|
||||
func newResource(ctx context.Context, instanceID string, detectors ...resource.Detector) (*resource.Resource, error) {
|
||||
serviceName := os.Getenv("OTEL_SERVICE_NAME")
|
||||
if serviceName == "" {
|
||||
serviceName = "centrifugo"
|
||||
}
|
||||
rs, err := resource.New(ctx,
|
||||
resource.WithSchemaURL(semconv.SchemaURL),
|
||||
resource.WithDetectors(detectors...),
|
||||
resource.WithAttributes(
|
||||
semconv.ServiceNameKey.String(serviceName),
|
||||
semconv.ServiceInstanceIDKey.String(instanceID),
|
||||
attribute.String("version", build.Version),
|
||||
),
|
||||
resource.WithFromEnv(),
|
||||
)
|
||||
if err != nil {
|
||||
// resource.New returns a usable partial resource together with the
|
||||
// error (malformed OTEL_RESOURCE_ATTRIBUTES, failed detector), but
|
||||
// exporting telemetry under a wrong or incomplete identity is worse
|
||||
// than failing startup: both causes are actionable, and orchestrator
|
||||
// restarts cover transient ones (e.g. metadata server not ready yet).
|
||||
return nil, fmt.Errorf("error building opentelemetry resource: %w", err)
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
func SetupTracing(ctx context.Context, cfg configtypes.OpenTelemetry, instanceID string) (*trace.TracerProvider, error) {
|
||||
exporterProtocol := os.Getenv("OTEL_EXPORTER_OTLP_PROTOCOL")
|
||||
if exporterProtocol == "" {
|
||||
exporterProtocol = "http/protobuf"
|
||||
}
|
||||
|
||||
exporter, err := createExporter(ctx, exporterProtocol, cfg.GoogleCloudADCAuth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detectors, err := resourceDetectors(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// labels/tags/resources that are common to all traces.
|
||||
rs, err := newResource(ctx, instanceID, detectors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
provider := trace.NewTracerProvider(
|
||||
trace.WithBatcher(exporter),
|
||||
trace.WithResource(rs),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(provider)
|
||||
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{}, // W3C Trace Context format; https://www.w3.org/TR/trace-context/
|
||||
),
|
||||
)
|
||||
|
||||
otel.SetErrorHandler(&ErrorHandlerImpl{})
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
type ErrorHandlerImpl struct{}
|
||||
|
||||
func (e ErrorHandlerImpl) Handle(err error) {
|
||||
log.Err(err).Msg("opentelemetry error")
|
||||
}
|
||||
|
||||
func createExporter(ctx context.Context, exporterProtocol string, googleCloudADCAuth bool) (*otlptrace.Exporter, error) {
|
||||
if exporterProtocol == "grpc" {
|
||||
var opts []otlptracegrpc.Option
|
||||
if googleCloudADCAuth {
|
||||
// Inject Google Cloud Application Default Credentials as per-RPC
|
||||
// OAuth2 tokens so the exporter can authenticate against Google
|
||||
// Cloud's OTLP endpoint (telemetry.googleapis.com). The access
|
||||
// token is minted lazily on first export and then cached and
|
||||
// auto-refreshed. Note: resolving ADC here (at startup) may do a
|
||||
// one-time metadata-server probe when running on GCE without an
|
||||
// explicit credentials file.
|
||||
creds, err := oauth.NewApplicationDefault(ctx, googleCloudAuthScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating Google Cloud application default credentials: %w", err)
|
||||
}
|
||||
opts = append(opts, otlptracegrpc.WithDialOption(grpc.WithPerRPCCredentials(creds)))
|
||||
}
|
||||
return otlptracegrpc.New(ctx, opts...)
|
||||
}
|
||||
|
||||
if exporterProtocol == "http/protobuf" {
|
||||
var opts []otlptracehttp.Option
|
||||
if googleCloudADCAuth {
|
||||
// Same as the gRPC path, but ADC tokens are attached by an OAuth2
|
||||
// http.Client transport (which also refreshes them automatically)
|
||||
// since the HTTP exporter has no per-RPC credentials concept.
|
||||
client, err := googleCloudADCHTTPClient(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, otlptracehttp.WithHTTPClient(client))
|
||||
}
|
||||
return otlptracehttp.New(ctx, opts...)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported exporter protocol: %s", exporterProtocol)
|
||||
}
|
||||
|
||||
// googleCloudADCHTTPClient returns an *http.Client that authenticates outgoing
|
||||
// requests with Google Cloud Application Default Credentials. The OAuth2
|
||||
// transport mints the access token lazily on first request and then caches and
|
||||
// auto-refreshes it. Resolving ADC (here, at startup) may perform a one-time
|
||||
// metadata-server probe when running on GCE without an explicit credentials file.
|
||||
func googleCloudADCHTTPClient(ctx context.Context) (*http.Client, error) {
|
||||
ts, err := google.DefaultTokenSource(ctx, googleCloudAuthScope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating Google Cloud application default credentials: %w", err)
|
||||
}
|
||||
return oauth2.NewClient(ctx, ts), nil
|
||||
}
|
||||
121
internal/telemetry/provider_test.go
Normal file
121
internal/telemetry/provider_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||
)
|
||||
|
||||
func resourceAttr(t *testing.T, attrs []attribute.KeyValue, key attribute.Key) (string, bool) {
|
||||
t.Helper()
|
||||
for _, kv := range attrs {
|
||||
if kv.Key == key {
|
||||
return kv.Value.AsString(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestNewResourceDefaults(t *testing.T) {
|
||||
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "")
|
||||
|
||||
rs, err := newResource(context.Background(), "node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
attrs := rs.Attributes()
|
||||
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceNameKey); v != "centrifugo" {
|
||||
t.Fatalf("unexpected service.name: %q", v)
|
||||
}
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceInstanceIDKey); v != "node-1" {
|
||||
t.Fatalf("unexpected service.instance.id: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResourceEnvOverridesAndMerges(t *testing.T) {
|
||||
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "gcp.project_id=my-project,service.instance.id=custom-id")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "my-centrifugo")
|
||||
|
||||
rs, err := newResource(context.Background(), "node-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
attrs := rs.Attributes()
|
||||
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceNameKey); v != "my-centrifugo" {
|
||||
t.Fatalf("unexpected service.name: %q", v)
|
||||
}
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceInstanceIDKey); v != "custom-id" {
|
||||
t.Fatalf("env must override default service.instance.id, got: %q", v)
|
||||
}
|
||||
if v, ok := resourceAttr(t, attrs, "gcp.project_id"); !ok || v != "my-project" {
|
||||
t.Fatalf("OTEL_RESOURCE_ATTRIBUTES not merged into resource, gcp.project_id: %q (present: %v)", v, ok)
|
||||
}
|
||||
if v, ok := resourceAttr(t, attrs, "version"); !ok || v == "" {
|
||||
t.Fatalf("version attribute missing: %q (present: %v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResourceMalformedEnv(t *testing.T) {
|
||||
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "missing-equals-sign")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "")
|
||||
|
||||
if _, err := newResource(context.Background(), "node-1"); err == nil {
|
||||
t.Fatal("expected error for malformed OTEL_RESOURCE_ATTRIBUTES")
|
||||
}
|
||||
}
|
||||
|
||||
type errorDetector struct{}
|
||||
|
||||
func (errorDetector) Detect(context.Context) (*resource.Resource, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func TestNewResourceDetectorError(t *testing.T) {
|
||||
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "")
|
||||
|
||||
if _, err := newResource(context.Background(), "node-1", errorDetector{}); err == nil {
|
||||
t.Fatal("expected error when detector fails")
|
||||
}
|
||||
}
|
||||
|
||||
type staticDetector []attribute.KeyValue
|
||||
|
||||
func (d staticDetector) Detect(context.Context) (*resource.Resource, error) {
|
||||
return resource.NewSchemaless(d...), nil
|
||||
}
|
||||
|
||||
func TestNewResourceDetectorPrecedence(t *testing.T) {
|
||||
t.Setenv("OTEL_RESOURCE_ATTRIBUTES", "cloud.region=env-region")
|
||||
t.Setenv("OTEL_SERVICE_NAME", "")
|
||||
|
||||
det := staticDetector{
|
||||
attribute.String("cloud.region", "detected-region"),
|
||||
attribute.String("k8s.cluster.name", "detected-cluster"),
|
||||
semconv.ServiceNameKey.String("detected-name"),
|
||||
}
|
||||
rs, err := newResource(context.Background(), "node-1", det)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
attrs := rs.Attributes()
|
||||
|
||||
if v, _ := resourceAttr(t, attrs, "cloud.region"); v != "env-region" {
|
||||
t.Fatalf("env must override detector, cloud.region: %q", v)
|
||||
}
|
||||
if v, _ := resourceAttr(t, attrs, "k8s.cluster.name"); v != "detected-cluster" {
|
||||
t.Fatalf("detector attribute lost, k8s.cluster.name: %q", v)
|
||||
}
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceNameKey); v != "centrifugo" {
|
||||
t.Fatalf("Centrifugo defaults must override detector, service.name: %q", v)
|
||||
}
|
||||
if v, _ := resourceAttr(t, attrs, semconv.ServiceInstanceIDKey); v != "node-1" {
|
||||
t.Fatalf("unexpected service.instance.id: %q", v)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user