// Package telemetry provides the shared OpenTelemetry runtime bootstrap for the // Scrabble services (backend, gateway and the Telegram connector). New builds the // tracer and meter providers from a Config, registers them as the OpenTelemetry // globals and installs the W3C trace-context/baggage propagators; the per-service // HTTP/RPC middleware and instruments live in the owning service. // // Three exporters are supported per signal: "none" (the default — no collector is // required locally or in CI), "stdout" (debugging) and "otlp" (gRPC export to a // collector). The OTLP endpoint and security are taken from the standard // OTEL_EXPORTER_OTLP_* environment variables read by the SDK, so no bespoke // configuration is introduced; the collector itself is stood up with the deploy // (PLAN.md Stage 14). package telemetry import ( "context" "errors" "fmt" "go.opentelemetry.io/contrib/instrumentation/runtime" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/propagation" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/trace" ) // Exporter selectors supported per signal. const ( ExporterNone = "none" ExporterStdout = "stdout" ExporterOTLP = "otlp" ) // Config selects a service's telemetry providers: the reported service name and // the per-signal exporters. type Config struct { // ServiceName is reported as the OpenTelemetry service.name resource. ServiceName string // TracesExporter is one of ExporterNone, ExporterStdout or ExporterOTLP. TracesExporter string // MetricsExporter is one of ExporterNone, ExporterStdout or ExporterOTLP. MetricsExporter string } // DefaultConfig returns the default telemetry configuration for serviceName: both // exporters off, so no collector is required locally or in CI. func DefaultConfig(serviceName string) Config { return Config{ ServiceName: serviceName, TracesExporter: ExporterNone, MetricsExporter: ExporterNone, } } // Validate reports whether the configuration names a service and selects // supported exporters. func (c Config) Validate() error { if c.ServiceName == "" { return errors.New("telemetry: ServiceName must not be empty") } if err := validateExporter("traces", c.TracesExporter); err != nil { return err } return validateExporter("metrics", c.MetricsExporter) } func validateExporter(kind, value string) error { switch value { case ExporterNone, ExporterStdout, ExporterOTLP: return nil default: return fmt.Errorf("telemetry: unsupported %s exporter %q", kind, value) } } // Runtime owns a service's OpenTelemetry providers. type Runtime struct { tracerProvider *sdktrace.TracerProvider meterProvider *sdkmetric.MeterProvider } // New constructs the telemetry runtime, registers the global providers and the // W3C trace-context/baggage propagators, and returns the Runtime. Callers must // invoke Runtime.Shutdown during process exit. The OTLP exporters dial lazily, so // New does not fail when no collector is reachable. func New(ctx context.Context, cfg Config) (*Runtime, error) { if err := cfg.Validate(); err != nil { return nil, err } res, err := resource.New(ctx, resource.WithAttributes( attribute.String("service.name", cfg.ServiceName), )) if err != nil { return nil, fmt.Errorf("telemetry: build resource: %w", err) } tracerProvider, err := newTracerProvider(ctx, cfg, res) if err != nil { return nil, fmt.Errorf("telemetry: build tracer provider: %w", err) } meterProvider, err := newMeterProvider(ctx, cfg, res) if err != nil { _ = tracerProvider.Shutdown(ctx) return nil, fmt.Errorf("telemetry: build meter provider: %w", err) } otel.SetTracerProvider(tracerProvider) otel.SetMeterProvider(meterProvider) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) return &Runtime{tracerProvider: tracerProvider, meterProvider: meterProvider}, nil } // TracerProvider returns the runtime tracer provider, or the global one when r is // not initialised. func (r *Runtime) TracerProvider() trace.TracerProvider { if r == nil || r.tracerProvider == nil { return otel.GetTracerProvider() } return r.tracerProvider } // MeterProvider returns the runtime meter provider, or the global one when r is // not initialised. func (r *Runtime) MeterProvider() metric.MeterProvider { if r == nil || r.meterProvider == nil { return otel.GetMeterProvider() } return r.meterProvider } // StartRuntimeMetrics begins collecting Go runtime metrics (heap, GC, goroutines) // against the runtime meter provider. It is a no-op observer set: nothing is // exported while the metrics exporter is "none". Call it once after New. func (r *Runtime) StartRuntimeMetrics() error { return runtime.Start(runtime.WithMeterProvider(r.MeterProvider())) } // Shutdown flushes both providers within ctx. func (r *Runtime) Shutdown(ctx context.Context) error { if r == nil { return nil } var err error if r.meterProvider != nil { err = errors.Join(err, r.meterProvider.Shutdown(ctx)) } if r.tracerProvider != nil { err = errors.Join(err, r.tracerProvider.Shutdown(ctx)) } return err } func newTracerProvider(ctx context.Context, cfg Config, res *resource.Resource) (*sdktrace.TracerProvider, error) { switch cfg.TracesExporter { case ExporterNone: return sdktrace.NewTracerProvider(sdktrace.WithResource(res)), nil case ExporterStdout: exporter, err := stdouttrace.New() if err != nil { return nil, fmt.Errorf("stdout trace exporter: %w", err) } return sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), sdktrace.WithResource(res), ), nil case ExporterOTLP: exporter, err := otlptracegrpc.New(ctx) if err != nil { return nil, fmt.Errorf("otlp trace exporter: %w", err) } return sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter), sdktrace.WithResource(res), ), nil default: return nil, fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter) } } func newMeterProvider(ctx context.Context, cfg Config, res *resource.Resource) (*sdkmetric.MeterProvider, error) { switch cfg.MetricsExporter { case ExporterNone: return sdkmetric.NewMeterProvider(sdkmetric.WithResource(res)), nil case ExporterStdout: exporter, err := stdoutmetric.New() if err != nil { return nil, fmt.Errorf("stdout metric exporter: %w", err) } return sdkmetric.NewMeterProvider( sdkmetric.WithResource(res), sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)), ), nil case ExporterOTLP: exporter, err := otlpmetricgrpc.New(ctx) if err != nil { return nil, fmt.Errorf("otlp metric exporter: %w", err) } return sdkmetric.NewMeterProvider( sdkmetric.WithResource(res), sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)), ), nil default: return nil, fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter) } }