Enhance observability and error handling in NATS integration. Introduce comprehensive OpenTelemetry support with environment-based sampling and semantic attributes for tracing and metrics. Implement an advisory dead-letter queue (DLQ) handler for managing message delivery failures. Update NATS consumer to utilize structured logging and improve error handling strategies. Refactor configuration files for OpenTelemetry collector in both development and production environments, ensuring robust telemetry integration. Enhance documentation to reflect new features and best practices for observability.

This commit is contained in:
windyboy
2025-11-18 11:47:35 +08:00
parent 5d05237283
commit 7f44b5389d
31 changed files with 2546 additions and 476 deletions
+248
View File
@@ -0,0 +1,248 @@
package telemetry
import (
"caatsm/internal/infra/buildinfo"
"caatsm/internal/infra/config"
"context"
"crypto/tls"
"fmt"
"os"
"sync"
"time"
"github.com/google/uuid"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
"go.uber.org/zap"
)
var (
// instanceID stores a unique identifier for this running process instance.
// It is initialized once at startup and remains stable for the lifetime of the process.
instanceID string
instanceIDOnce sync.Once
)
// InitOTEL initializes the OpenTelemetry SDK with proper resource attributes,
// sampling configuration, and exporters. This should be called once at application startup.
// logger is optional; if provided, sensitive telemetry configuration will be logged at debug level.
func InitOTEL(ctx context.Context, cfg *config.Config, logger *zap.Logger) error {
if !cfg.Telemetry.Enabled {
return nil
}
// Log sensitive telemetry configuration to internal debug logs only
if logger != nil {
logger.Debug("Initializing OpenTelemetry",
zap.String("telemetry.endpoint", cfg.Telemetry.Endpoint),
zap.Bool("telemetry.insecure", cfg.Telemetry.Insecure),
)
}
// Create resource with comprehensive service information
res, err := createResource(ctx, cfg)
if err != nil {
return fmt.Errorf("failed to create OTEL resource: %w", err)
}
// Initialize tracing
if err := initTracing(ctx, cfg, res); err != nil {
return fmt.Errorf("failed to initialize tracing: %w", err)
}
// Initialize metrics
if err := initMetrics(ctx, cfg, res); err != nil {
return fmt.Errorf("failed to initialize metrics: %w", err)
}
return nil
}
// getInstanceID returns a unique identifier for this running process instance.
// It checks environment variables (POD_NAME, CONTAINER_ID, HOSTNAME) first, then generates
// a UUID if no environment variable is available. The ID is initialized once and remains
// stable for the lifetime of the process.
func getInstanceID() string {
instanceIDOnce.Do(func() {
// Check for Kubernetes pod name first (most common in containerized deployments)
if podName := os.Getenv("POD_NAME"); podName != "" {
instanceID = podName
return
}
// Check for container ID (Docker, containerd, etc.)
if containerID := os.Getenv("CONTAINER_ID"); containerID != "" {
instanceID = containerID
return
}
// Check for HOSTNAME (often set in containers)
if hostname := os.Getenv("HOSTNAME"); hostname != "" {
// Use hostname if it's not a generic default
if hostname != "localhost" && hostname != "localhost.localdomain" {
instanceID = hostname
return
}
}
// Generate a UUID for this process instance
instanceID = uuid.NewString()
})
return instanceID
}
// createResource creates a resource with standard and custom attributes.
// Sensitive infrastructure details (endpoint, insecure flag) are excluded from resource
// attributes to prevent leakage. These values are logged internally at debug level if
// a logger is provided to InitOTEL.
func createResource(ctx context.Context, cfg *config.Config) (*resource.Resource, error) {
// Get the runtime instance ID (falls back to buildinfo.Commit if needed)
runtimeInstanceID := getInstanceID()
if runtimeInstanceID == "" {
// Final fallback to build commit if somehow instance ID is empty
runtimeInstanceID = buildinfo.Commit
}
attrs := []attribute.KeyValue{
// Standard semantic conventions
semconv.ServiceName("caatsm"),
semconv.ServiceVersion(buildinfo.Version),
semconv.ServiceInstanceID(runtimeInstanceID),
semconv.ServiceNamespace("airport"),
// Custom attributes
attribute.String("service.component", "receiver"),
attribute.String("service.environment", getEnvironment()),
attribute.String("build.commit", buildinfo.Commit),
attribute.String("build.built_at", buildinfo.BuiltAt),
}
// Add non-sensitive indicator for telemetry endpoint configuration
// (without exposing the actual endpoint value)
if cfg.Telemetry.Endpoint != "" {
attrs = append(attrs, attribute.Bool("telemetry.endpoint.configured", true))
} else {
attrs = append(attrs, attribute.Bool("telemetry.endpoint.configured", false))
}
return resource.New(ctx, resource.WithAttributes(attrs...))
}
// initTracing sets up the trace provider with appropriate sampling
func initTracing(ctx context.Context, cfg *config.Config, res *resource.Resource) error {
var traceExporterOptions []otlptracehttp.Option
traceExporterOptions = append(traceExporterOptions, otlptracehttp.WithEndpoint(cfg.Telemetry.Endpoint))
if cfg.Telemetry.Insecure {
traceExporterOptions = append(traceExporterOptions, otlptracehttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS13,
}))
}
traceExporter, err := otlptracehttp.New(ctx, traceExporterOptions...)
if err != nil {
return fmt.Errorf("failed to create trace exporter: %w", err)
}
// Configure sampling based on environment
sampler := getSamplerForEnvironment(getEnvironment())
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExporter,
sdktrace.WithBatchTimeout(1*time.Second),
sdktrace.WithMaxExportBatchSize(512),
sdktrace.WithMaxQueueSize(2048),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sampler)),
)
otel.SetTracerProvider(tracerProvider)
return nil
}
// initMetrics sets up the meter provider
func initMetrics(ctx context.Context, cfg *config.Config, res *resource.Resource) error {
var metricExporterOptions []otlpmetrichttp.Option
metricExporterOptions = append(metricExporterOptions, otlpmetrichttp.WithEndpoint(cfg.Telemetry.Endpoint))
if cfg.Telemetry.Insecure {
metricExporterOptions = append(metricExporterOptions, otlpmetrichttp.WithTLSClientConfig(&tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}))
}
metricExporter, err := otlpmetrichttp.New(ctx, metricExporterOptions...)
if err != nil {
return fmt.Errorf("failed to create metric exporter: %w", err)
}
meterProvider := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter,
sdkmetric.WithInterval(30*time.Second),
)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(meterProvider)
return nil
}
// getSamplerForEnvironment returns appropriate sampling strategy for each environment
func getSamplerForEnvironment(env string) sdktrace.Sampler {
switch env {
case "prod", "production":
// 1% sampling in production to control costs and performance
return sdktrace.TraceIDRatioBased(0.01)
case "staging":
// 10% sampling in staging for better observability
return sdktrace.TraceIDRatioBased(0.1)
case "test", "testing":
// Always sample in testing for complete coverage
return sdktrace.AlwaysSample()
default:
// 100% sampling in development for debugging
return sdktrace.AlwaysSample()
}
}
// ShutdownOTEL gracefully shuts down the OTEL providers
func ShutdownOTEL(ctx context.Context) error {
var errs []error
if tracerProvider, ok := otel.GetTracerProvider().(*sdktrace.TracerProvider); ok {
if err := tracerProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("failed to shutdown tracer provider: %w", err))
}
}
if meterProvider, ok := otel.GetMeterProvider().(*sdkmetric.MeterProvider); ok {
if err := meterProvider.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("failed to shutdown meter provider: %w", err))
}
}
if len(errs) > 0 {
return fmt.Errorf("OTEL shutdown errors: %v", errs)
}
return nil
}
// getEnvironment returns the current environment from GO_ENV
func getEnvironment() string {
if env := os.Getenv("GO_ENV"); env != "" {
return env
}
return "dev"
}