// Package otelsetup initialises the OpenTelemetry SDK for the LibNovel backend. // // It reads two environment variables: // // OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP endpoint; accepts either a full // URL ("https://otel.example.com") or a bare // host[:port] ("otel-collector:4318"). // TLS is used when the value starts with "https://". // OTEL_SERVICE_NAME — service name reported in traces (default: "backend") // // When OTEL_EXPORTER_OTLP_ENDPOINT is empty the function is a no-op: it // returns a nil shutdown func and the default slog.Logger, so callers never // need to branch on it. // // Usage in main.go: // // shutdown, log, err := otelsetup.Init(ctx, version) // if err != nil { return err } // if shutdown != nil { defer shutdown() } package otelsetup import ( "context" "fmt" "log/slog" "os" "strings" "time" "go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" otellog "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" ) // Init sets up TracerProvider and LoggerProvider that export via OTLP/HTTP. // // Returns: // - shutdown: flushes and stops both providers (nil when OTel is disabled). // - logger: an slog.Logger bridged to OTel logs (falls back to default when disabled). // - err: non-nil only on SDK initialisation failure. func Init(ctx context.Context, version string) (shutdown func(), logger *slog.Logger, err error) { rawEndpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") if rawEndpoint == "" { return nil, slog.Default(), nil // OTel disabled — not an error } // WithEndpoint expects a host[:port] value — no scheme. // Support both "https://otel.example.com" and "otel-collector:4318". useTLS := strings.HasPrefix(rawEndpoint, "https://") endpoint := strings.TrimPrefix(rawEndpoint, "https://") endpoint = strings.TrimPrefix(endpoint, "http://") serviceName := os.Getenv("OTEL_SERVICE_NAME") if serviceName == "" { serviceName = "backend" } // ── Shared resource ─────────────────────────────────────────────────────── res, err := resource.New(ctx, resource.WithAttributes( semconv.ServiceName(serviceName), semconv.ServiceVersion(version), ), ) if err != nil { return nil, slog.Default(), fmt.Errorf("otelsetup: create resource: %w", err) } // ── Trace provider ──────────────────────────────────────────────────────── traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(endpoint)} if !useTLS { traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) } traceExp, err := otlptracehttp.New(ctx, traceOpts...) if err != nil { return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP trace exporter: %w", err) } tp := sdktrace.NewTracerProvider( sdktrace.WithBatcher(traceExp), sdktrace.WithResource(res), sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.2))), ) otel.SetTracerProvider(tp) // ── Log provider ────────────────────────────────────────────────────────── logOpts := []otlploghttp.Option{otlploghttp.WithEndpoint(endpoint)} if !useTLS { logOpts = append(logOpts, otlploghttp.WithInsecure()) } logExp, err := otlploghttp.New(ctx, logOpts...) if err != nil { return nil, slog.Default(), fmt.Errorf("otelsetup: create OTLP log exporter: %w", err) } lp := log.NewLoggerProvider( log.WithProcessor(log.NewBatchProcessor(logExp)), log.WithResource(res), ) otellog.SetLoggerProvider(lp) // Bridge slog → OTel logs. Structured fields and trace IDs are forwarded // automatically; Grafana can correlate log lines with Tempo traces. otelLogger := otelslog.NewLogger(serviceName) shutdown = func() { shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = tp.Shutdown(shutCtx) _ = lp.Shutdown(shutCtx) } return shutdown, otelLogger, nil }