Files
OwnCord/Server/telemetry/telemetry_otel.go
T
J3vbandClaude Opus 4.8 4fc21cb372 feat(server): logging & error-visibility hardening
Make server failures debuggable without leaking secrets:
- configurable stdout log level (config.yaml logging.level + OWNCORD_LOGGING_LEVEL)
- preserve the DB cause in ErrInternal wraps; log auth-DB failures distinctly
  from bad tokens; log the previously-silent expired-session cleanup goroutine
- route HTTP handler panics through slog (was chi stderr-only, invisible to
  the admin log stream)
- stackutil: argument-free panic stacks so key/token bytes never reach the
  admin ring buffer / SSE; slog.LogValuer redaction on VoiceConfig/GitHubConfig/
  GIFConfig/Config and db.User/db.Session
- logctx: req_id/trace_id correlation on ...Context log calls

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 11:07:12 +02:00

307 lines
11 KiB
Go

//go:build otel
// Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only
// with `-tags otel`, matching the wazero build-tag pattern used
// elsewhere in the repo. The default build ships telemetry_default.go with a
// no-op provider so sqlite-only binaries do not pull the OTel SDK in.
//
// Exporter modes (config.TelemetryConfig.Exporter):
//
// "none" — no-op provider; same as the default build.
// "prometheus" — pull-based metrics via a /metrics handler, spans are still
// processed by a tracer provider with no exporter.
// "otlp" — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND
// pull-based metrics via the Prometheus exporter (operators
// typically want both).
//
// The concrete Provider adapts our tiny telemetry.* API onto the upstream
// OTel SDK so the rest of the codebase never depends on the SDK directly.
package telemetry
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"sync"
"github.com/owncord/server/config"
promclient "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
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.40.0"
"go.opentelemetry.io/otel/trace"
)
// otelProvider adapts the OTel SDK to the telemetry.Provider interface.
type otelProvider struct {
cfg config.TelemetryConfig
promHandler http.Handler
tp *sdktrace.TracerProvider
mp *sdkmetric.MeterProvider
serviceName string
}
// Init wires the OTel SDK according to cfg and installs it as the global
// Provider. The returned ShutdownFunc flushes the batchers and releases
// exporter resources; it is safe to call more than once.
func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) {
if !cfg.Enabled || cfg.Exporter == "" || cfg.Exporter == "none" {
SetGlobal(noopProvider{})
return func(context.Context) error { return nil }, nil
}
svcName := cfg.ServiceName
if svcName == "" {
svcName = "owncord-server"
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(svcName),
),
)
if err != nil {
return nil, fmt.Errorf("telemetry: resource: %w", err)
}
provider := &otelProvider{cfg: cfg, serviceName: svcName}
// ── Tracing ────────────────────────────────────────────────────────────
tpOpts := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
if cfg.Exporter == "otlp" {
endpoint := cfg.OTLPEndpoint
if endpoint == "" {
endpoint = "localhost:4317"
}
otlpOpts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(endpoint),
}
// OTLPInsecure honours the explicit operator opt-in for plaintext
// gRPC; production deployments should leave it false and provide
// a TLS endpoint.
if cfg.OTLPInsecure {
otlpOpts = append(otlpOpts, otlptracegrpc.WithInsecure())
}
traceExp, err := otlptracegrpc.New(ctx, otlpOpts...)
if err != nil {
return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err)
}
tpOpts = append(tpOpts, sdktrace.WithBatcher(traceExp))
}
provider.tp = sdktrace.NewTracerProvider(tpOpts...)
otel.SetTracerProvider(provider.tp)
// ── Metrics ────────────────────────────────────────────────────────────
// Both prometheus and otlp modes surface metrics via a pull-based
// Prometheus endpoint. OTLP tracing does not preclude Prometheus metrics
// — operators usually want both — so we wire the exporter unconditionally
// for the two real modes.
reg := promclient.NewRegistry()
promExp, err := otelprom.New(otelprom.WithRegisterer(reg))
if err != nil {
// Shut the trace provider down so the OTLP exporter's gRPC
// connection (if any) is torn down. Init returning an error must
// not leak resources.
_ = provider.tp.Shutdown(ctx)
return nil, fmt.Errorf("telemetry: prometheus exporter: %w", err)
}
provider.mp = sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(promExp),
)
otel.SetMeterProvider(provider.mp)
provider.promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
// Drop the cached AppMetrics bundle BEFORE publishing the new global
// provider. A concurrent NewAppMetrics() caller that observes the old
// no-op provider and the stale cache is harmless (it just re-populates
// once); the failure mode we avoid is a caller seeing the new provider
// but still reading the old cached (no-op) instruments.
resetAppMetricsForInit()
SetGlobal(provider)
var once sync.Once
return func(shutCtx context.Context) error {
var rerr error
once.Do(func() {
var errs []error
if provider.tp != nil {
if err := provider.tp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if provider.mp != nil {
if err := provider.mp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
rerr = fmt.Errorf("telemetry shutdown: %w", errors.Join(errs...))
}
SetGlobal(noopProvider{})
})
return rerr
}, nil
}
// Tracer returns an otel-backed tracer for the given instrumentation scope.
func (p *otelProvider) Tracer(name string) Tracer {
return &otelTracer{inner: p.tp.Tracer(name)}
}
// Meter returns an otel-backed meter for the given instrumentation scope.
func (p *otelProvider) Meter(name string) Meter {
return &otelMeter{inner: p.mp.Meter(name)}
}
// HTTPMiddleware wraps next with otelhttp so every REST request becomes a
// span named after its route pattern.
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "http.server",
otelhttp.WithServerName(p.serviceName),
)
}
// PrometheusHandler returns the /metrics handler backed by the active
// exporter registry.
func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler }
// TraceIDFromContext returns the active trace ID as a hex string, or "" when no
// span is recording in ctx. Used to stamp log records with trace_id so logs
// correlate with traces.
func TraceIDFromContext(ctx context.Context) string {
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
return sc.TraceID().String()
}
return ""
}
// ── Tracer / Span adapters ─────────────────────────────────────────────────
type otelTracer struct{ inner trace.Tracer }
func (t *otelTracer) Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) {
ctx, span := t.inner.Start(ctx, name, trace.WithAttributes(convertAttrs(attrs)...))
return ctx, &otelSpan{inner: span}
}
type otelSpan struct{ inner trace.Span }
func (s *otelSpan) End() { s.inner.End() }
func (s *otelSpan) SetAttributes(attrs ...Attr) { s.inner.SetAttributes(convertAttrs(attrs)...) }
func (s *otelSpan) RecordError(err error) {
if err == nil {
return
}
s.inner.RecordError(err)
}
// ── Meter / instrument adapters ────────────────────────────────────────────
type otelMeter struct{ inner metric.Meter }
func (m *otelMeter) Counter(name, description string) Counter {
c, err := m.inner.Int64Counter(name, metric.WithDescription(description))
if err != nil {
return noopCounter{}
}
return &otelCounter{inner: c}
}
func (m *otelMeter) Histogram(name, description, unit string) Histogram {
opts := []metric.Float64HistogramOption{metric.WithDescription(description)}
if unit != "" {
opts = append(opts, metric.WithUnit(unit))
}
h, err := m.inner.Float64Histogram(name, opts...)
if err != nil {
return noopHistogram{}
}
return &otelHistogram{inner: h}
}
func (m *otelMeter) Gauge(name, description string) Gauge {
g, err := m.inner.Float64Gauge(name, metric.WithDescription(description))
if err != nil {
return noopGauge{}
}
return &otelGauge{inner: g}
}
type otelCounter struct{ inner metric.Int64Counter }
func (c *otelCounter) Add(ctx context.Context, delta int64, attrs ...Attr) {
c.inner.Add(ctx, delta, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelHistogram struct{ inner metric.Float64Histogram }
func (h *otelHistogram) Record(ctx context.Context, value float64, attrs ...Attr) {
h.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelGauge struct{ inner metric.Float64Gauge }
func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) {
g.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
// convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown
// types are rendered via fmt.Sprint so callers never panic on exotic values.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields
// in OwnCord are unsigned, and routing them through the default fmt.Sprint
// path would encode them as string attributes and break metric aggregation.
func convertAttrs(in []Attr) []attribute.KeyValue {
if len(in) == 0 {
return nil
}
out := make([]attribute.KeyValue, 0, len(in))
for _, a := range in {
switch v := a.Value.(type) {
case string:
out = append(out, attribute.String(a.Key, v))
case int:
out = append(out, attribute.Int(a.Key, v))
case int32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case int64:
out = append(out, attribute.Int64(a.Key, v))
case uint:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint64:
// Most uint64 values in OwnCord (ids, seqs) fit comfortably
// within int64 range. A wrapped negative would corrupt
// metric aggregation, so we fall back to a string for the
// pathological case rather than silently misreporting.
if v <= math.MaxInt64 {
out = append(out, attribute.Int64(a.Key, int64(v)))
} else {
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
case float32:
out = append(out, attribute.Float64(a.Key, float64(v)))
case float64:
out = append(out, attribute.Float64(a.Key, v))
case bool:
out = append(out, attribute.Bool(a.Key, v))
default:
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
}
return out
}