Files
ahp e05c4327d9 fix(observability): use schemaless resource attrs to avoid schema URL conflict
SetupTracing merged resource.Default() (schema 1.43.0 under otel 1.45.0)
with semconv/v1.24.0's schema URL, so resource.Merge errored with
'conflicting Schema URL' and the backend exited at startup whenever an
OTLP endpoint was configured. resource.NewSchemaless keeps service.name
without pinning a schema, surviving otel major bumps.
2026-10-09 20:35:24 +03:30

114 lines
3.0 KiB
Go

package observability
import (
"context"
"log/slog"
"os"
"strings"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
// Config holds optional OpenTelemetry settings.
type Config struct {
ServiceName string
OTLPEndpoint string // empty = noop tracer
}
var (
logger *slog.Logger
tracer trace.Tracer = noop.NewTracerProvider().Tracer("pargar")
)
// SetupJSONLogger installs a JSON slog default logger.
func SetupJSONLogger() *slog.Logger {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
logger = slog.New(handler)
slog.SetDefault(logger)
return logger
}
func L() *slog.Logger {
if logger == nil {
return slog.Default()
}
return logger
}
// Activity emits a structured domain activity log line.
func Activity(action string, attrs ...any) {
args := make([]any, 0, len(attrs)+2)
args = append(args, "action", action)
args = append(args, attrs...)
L().Info("activity", args...)
}
// SetupTracing configures OTLP HTTP exporter when endpoint is set; otherwise noop.
// Returns a shutdown func (always non-nil).
func SetupTracing(ctx context.Context, cfg Config) (func(context.Context) error, error) {
name := strings.TrimSpace(cfg.ServiceName)
if name == "" {
name = "pargar-api"
}
endpoint := strings.TrimSpace(cfg.OTLPEndpoint)
if endpoint == "" {
tp := noop.NewTracerProvider()
otel.SetTracerProvider(tp)
tracer = tp.Tracer(name)
L().Info("otel tracing disabled (no OTEL_EXPORTER_OTLP_ENDPOINT)")
return func(context.Context) error { return nil }, nil
}
endpoint = strings.TrimPrefix(endpoint, "https://")
endpoint = strings.TrimPrefix(endpoint, "http://")
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpoint(endpoint),
otlptracehttp.WithInsecure(),
)
if err != nil {
return nil, err
}
// Schemaless: a non-empty but different schema URL here (from an older
// semconv pin) makes resource.Merge fail as soon as the SDK's
// resource.Default() schema URL changes across otel upgrades.
res, err := resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName(name),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
tracer = tp.Tracer(name)
L().Info("otel tracing enabled", "endpoint", endpoint, "service", name)
return tp.Shutdown, nil
}
func Tracer() trace.Tracer { return tracer }
// StartSpan starts a named span from context.
func StartSpan(ctx context.Context, name string) (context.Context, trace.Span) {
return Tracer().Start(ctx, name)
}
// DurationMs helper for middleware logs.
func DurationMs(start time.Time) int64 {
return time.Since(start).Milliseconds()
}