internal/observability/otel.go
Rama main · réplica de lectura del repositorio original.
package observability
import (
"context"
"errors"
"fmt"
"net/http"
"slices"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
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.26.0"
"go.opentelemetry.io/otel/trace"
"github.com/sarathsp06/sparrow"
)
// metricExportInterval is how often the OTLP metric reader flushes.
const metricExportInterval = 30 * time.Second
// OTLP transport protocols, as spelled by OTEL_EXPORTER_OTLP_PROTOCOL.
const (
ProtocolHTTPProtobuf = "http/protobuf"
ProtocolGRPC = "grpc"
)
// OTLP signals, as spelled in Config.OTLPSignals.
const (
SignalTraces = "traces"
SignalMetrics = "metrics"
SignalLogs = "logs"
)
var allSignals = []string{SignalTraces, SignalMetrics, SignalLogs}
// Config holds OpenTelemetry configuration
type Config struct {
ServiceName string
ServiceVersion string
Environment string
// OTLPEndpoint gates export: empty disables it. The exporters read the
// standard OTEL_EXPORTER_OTLP_* env vars themselves (endpoint URL,
// headers, TLS, timeouts, per-signal overrides). A bare host:port
// without a scheme is still accepted and sent over plaintext.
OTLPEndpoint string
// OTLPProtocol is ProtocolHTTPProtobuf (default when empty) or ProtocolGRPC.
OTLPProtocol string
// OTLPSignals lists which of SignalTraces, SignalMetrics and SignalLogs
// are sent to OTLPEndpoint. Empty means all three. Backends such as
// Jaeger or Tempo accept traces only and answer the other exporters
// with "unknown service"; listing just "traces" never creates them.
OTLPSignals []string
// Prometheus also exposes every OTel metric for scraping through
// MetricsHandler, with or without OTLP export.
Prometheus bool
}
// DefaultConfig returns a default OpenTelemetry configuration.
// OTLPEndpoint is empty by default -- set it to enable export.
// When OTLPEndpoint is empty, Setup() is a no-op (no exporters created).
func DefaultConfig() *Config {
return &Config{
ServiceName: "sparrow",
ServiceVersion: sparrow.Version,
Environment: "development",
OTLPEndpoint: "", // empty = OTel export disabled
}
}
// Setup initializes OpenTelemetry with the provided configuration.
// When config.OTLPEndpoint is empty, no OTLP exporters are created (this
// avoids noisy connection errors when no collector is available); metrics are
// still collected when config.Prometheus is set, for MetricsHandler. With
// neither, Setup returns a no-op shutdown function.
func Setup(ctx context.Context, config *Config) (func(context.Context) error, error) {
noop := func(context.Context) error { return nil }
// Install the propagator unconditionally so trace context propagation
// works even without an exporter (e.g. inbound headers are parsed).
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
otlp := config.OTLPEndpoint != ""
if !otlp && !config.Prometheus {
return noop, nil
}
if otlp {
for _, signal := range config.OTLPSignals {
if !slices.Contains(allSignals, signal) {
return nil, fmt.Errorf("unknown OTLP signal %q (use %s)",
signal, strings.Join(allSignals, ", "))
}
}
switch config.OTLPProtocol {
case "":
config.OTLPProtocol = ProtocolHTTPProtobuf
case ProtocolHTTPProtobuf, ProtocolGRPC:
default:
return nil, fmt.Errorf("unsupported OTLP protocol %q (use %q or %q)",
config.OTLPProtocol, ProtocolGRPC, ProtocolHTTPProtobuf)
}
}
// Create resource with service information
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceNameKey.String(config.ServiceName),
semconv.ServiceVersionKey.String(config.ServiceVersion),
semconv.DeploymentEnvironmentKey.String(config.Environment),
),
)
if err != nil {
return nil, fmt.Errorf("failed to create resource: %w", err)
}
var shutdownFuncs []func(context.Context) error
// On partial failure, shut down already-built providers before returning
// so we don't leak exporter connections.
cleanup := func() {
for _, fn := range shutdownFuncs {
_ = fn(ctx)
}
}
var tracerProvider *sdktrace.TracerProvider
var loggerProvider *log.LoggerProvider
if otlp && config.exports(SignalTraces) {
tracerProvider, err = setupTracing(ctx, res, config)
if err != nil {
return nil, fmt.Errorf("failed to setup tracing: %w", err)
}
shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
}
meterProvider, promHandler, err := setupMetrics(ctx, res, config)
if err != nil {
cleanup()
return nil, fmt.Errorf("failed to setup metrics: %w", err)
}
shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
if otlp && config.exports(SignalLogs) {
loggerProvider, err = newLoggerProvider(ctx, config)
if err != nil {
cleanup()
return nil, fmt.Errorf("failed to setup logger: %w", err)
}
shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
}
// Install globals only after every provider was built successfully.
if tracerProvider != nil {
otel.SetTracerProvider(tracerProvider)
}
otel.SetMeterProvider(meterProvider)
if loggerProvider != nil {
otel.SetLoggerProvider(loggerProvider)
}
metricsHandler = promHandler
// Return shutdown function
return func(ctx context.Context) error {
var errs []error
for _, fn := range shutdownFuncs {
if err := fn(ctx); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
return fmt.Errorf("failed to shutdown OpenTelemetry: %w", errors.Join(errs...))
}
return nil
}, nil
}
// metricsHandler serves the Prometheus scrape endpoint; nil until Setup ran
// with Config.Prometheus.
var metricsHandler http.Handler
// MetricsHandler returns the handler serving every OTel metric in Prometheus
// text format, or nil when Setup did not enable Prometheus.
func MetricsHandler() http.Handler {
return metricsHandler
}
// exports reports whether signal is among the OTLP signals to export; an
// empty OTLPSignals list exports every signal.
func (c *Config) exports(signal string) bool {
return len(c.OTLPSignals) == 0 || slices.Contains(c.OTLPSignals, signal)
}
// legacyEndpoint reports whether endpoint is a bare host:port (the form
// Sparrow originally required). Those are passed explicitly as a plaintext
// endpoint; URL-form endpoints are left to the exporters' own env parsing,
// which derives TLS from the scheme.
func legacyEndpoint(endpoint string) bool {
return !strings.Contains(endpoint, "://")
}
// setupTracing configures OpenTelemetry tracing
func setupTracing(ctx context.Context, res *resource.Resource, config *Config) (*sdktrace.TracerProvider, error) {
var exporter sdktrace.SpanExporter
var err error
if config.OTLPProtocol == ProtocolGRPC {
var opts []otlptracegrpc.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlptracegrpc.WithEndpoint(config.OTLPEndpoint), otlptracegrpc.WithInsecure())
}
exporter, err = otlptracegrpc.New(ctx, opts...)
} else {
var opts []otlptracehttp.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlptracehttp.WithEndpoint(config.OTLPEndpoint), otlptracehttp.WithInsecure())
}
exporter, err = otlptracehttp.New(ctx, opts...)
}
if err != nil {
return nil, fmt.Errorf("failed to create OTLP trace exporter: %w", err)
}
// Create tracer provider
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
return tracerProvider, nil
}
func newLoggerProvider(ctx context.Context, config *Config) (*log.LoggerProvider, error) {
var exporter log.Exporter
var err error
if config.OTLPProtocol == ProtocolGRPC {
var opts []otlploggrpc.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlploggrpc.WithEndpoint(config.OTLPEndpoint), otlploggrpc.WithInsecure())
}
exporter, err = otlploggrpc.New(ctx, opts...)
} else {
var opts []otlploghttp.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlploghttp.WithEndpoint(config.OTLPEndpoint), otlploghttp.WithInsecure())
}
exporter, err = otlploghttp.New(ctx, opts...)
}
if err != nil {
return nil, fmt.Errorf("failed to create OTLP log exporter: %w", err)
}
loggerProvider := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(exporter)),
)
return loggerProvider, nil
}
// setupMetrics configures OpenTelemetry metrics: a periodic OTLP reader when
// an endpoint is configured, and a Prometheus pull reader (returned as its
// scrape handler) when config.Prometheus is set. Both read the same
// instruments, so every metric is available through either path.
func setupMetrics(ctx context.Context, res *resource.Resource, config *Config) (*sdkmetric.MeterProvider, http.Handler, error) {
opts := []sdkmetric.Option{sdkmetric.WithResource(res)}
if config.OTLPEndpoint != "" && config.exports(SignalMetrics) {
var exporter sdkmetric.Exporter
var err error
if config.OTLPProtocol == ProtocolGRPC {
var opts []otlpmetricgrpc.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlpmetricgrpc.WithEndpoint(config.OTLPEndpoint), otlpmetricgrpc.WithInsecure())
}
exporter, err = otlpmetricgrpc.New(ctx, opts...)
} else {
var opts []otlpmetrichttp.Option
if legacyEndpoint(config.OTLPEndpoint) {
opts = append(opts, otlpmetrichttp.WithEndpoint(config.OTLPEndpoint), otlpmetrichttp.WithInsecure())
}
exporter, err = otlpmetrichttp.New(ctx, opts...)
}
if err != nil {
return nil, nil, fmt.Errorf("failed to create OTLP metric exporter: %w", err)
}
opts = append(opts, sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter,
sdkmetric.WithInterval(metricExportInterval))))
}
var handler http.Handler
if config.Prometheus {
// A private registry keeps the scrape output to Sparrow's own
// metrics plus the standard Go runtime and process collectors.
registry := prometheus.NewRegistry()
registry.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
exporter, err := otelprom.New(otelprom.WithRegisterer(registry))
if err != nil {
return nil, nil, fmt.Errorf("failed to create Prometheus exporter: %w", err)
}
opts = append(opts, sdkmetric.WithReader(exporter))
handler = promhttp.HandlerFor(registry, promhttp.HandlerOpts{})
}
return sdkmetric.NewMeterProvider(opts...), handler, nil
}
// GetTracer returns a tracer for the given name
func GetTracer(name string) trace.Tracer {
return otel.Tracer(name, trace.WithInstrumentationVersion(sparrow.Version))
}
// GetMeter returns a meter for the given name
func GetMeter(name string) metric.Meter {
return otel.Meter(name, metric.WithInstrumentationVersion(sparrow.Version))
}
// SparrowMetrics holds application-specific metrics
type SparrowMetrics struct {
WebhookRegistrations metric.Int64Counter
EventsPushed metric.Int64Counter
}
// NewSparrowMetrics creates application-specific metrics
func NewSparrowMetrics() (*SparrowMetrics, error) {
meter := GetMeter("sparrow")
webhookRegistrations, err := meter.Int64Counter(
"sparrow_webhook_registrations_total",
metric.WithDescription("Total number of webhook registrations"),
)
if err != nil {
return nil, err
}
eventsPushed, err := meter.Int64Counter(
"sparrow_events_pushed_total",
metric.WithDescription("Total number of events pushed"),
)
if err != nil {
return nil, err
}
return &SparrowMetrics{
WebhookRegistrations: webhookRegistrations,
EventsPushed: eventsPushed,
}, nil
}