package telemetry import ( "context" "errors" "go.opentelemetry.io/otel" otellog "go.opentelemetry.io/otel/log" "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/propagation" sdklog "go.opentelemetry.io/otel/sdk/log" sdkmetric "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/metric/metricdata" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.24.0" "code.nochebuena.dev/go/logz" ) // ConsoleConfig holds the minimum OTel configuration needed for console/dev mode. // Only service identity fields are required — no OTLP endpoint. type ConsoleConfig struct { ServiceName string `env:"OTEL_SERVICE_NAME,required"` ServiceVersion string `env:"OTEL_SERVICE_VERSION" envDefault:"unknown"` Environment string `env:"OTEL_ENVIRONMENT" envDefault:"development"` } // NewConsole bootstraps the OTel SDK with logz-backed exporters for local development. // All signals (traces, metrics, OTel log records) are emitted as structured logz log // lines instead of being sent to a collector. // // Drop-in alternative to [New] for development environments. // // Warning: do not wire the OTel slog bridge alongside NewConsole — routing logz through // the OTel log API and back through logLogExporter creates a feedback loop. func NewConsole(ctx context.Context, logger logz.Logger, cfg ConsoleConfig) (func(context.Context) error, error) { res, err := resource.Merge( resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName(cfg.ServiceName), semconv.ServiceVersion(cfg.ServiceVersion), ), ) if err != nil { res = resource.Default() } tp := sdktrace.NewTracerProvider( sdktrace.WithSyncer(&logTraceExporter{logger: logger}), sdktrace.WithResource(res), ) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) mp := sdkmetric.NewMeterProvider( sdkmetric.WithReader(sdkmetric.NewPeriodicReader(&logMetricExporter{logger: logger})), sdkmetric.WithResource(res), ) otel.SetMeterProvider(mp) lp := sdklog.NewLoggerProvider( sdklog.WithProcessor(sdklog.NewBatchProcessor(&logLogExporter{logger: logger})), sdklog.WithResource(res), ) global.SetLoggerProvider(lp) shutdown := func(ctx context.Context) error { var errs []error if err := tp.Shutdown(ctx); err != nil { errs = append(errs, &providerErr{"trace", err}) } if err := mp.Shutdown(ctx); err != nil { errs = append(errs, &providerErr{"metric", err}) } if err := lp.Shutdown(ctx); err != nil { errs = append(errs, &providerErr{"log", err}) } return errors.Join(errs...) } return shutdown, nil } // --- trace exporter --- type logTraceExporter struct{ logger logz.Logger } func (e *logTraceExporter) ExportSpans(_ context.Context, spans []sdktrace.ReadOnlySpan) error { for _, s := range spans { e.logger.Info("otel: span", "name", s.Name(), "trace_id", s.SpanContext().TraceID(), "span_id", s.SpanContext().SpanID(), "parent_span_id", s.Parent().SpanID(), "kind", s.SpanKind(), "duration_ms", s.EndTime().Sub(s.StartTime()).Milliseconds(), "status", s.Status().Code, ) } return nil } func (e *logTraceExporter) Shutdown(_ context.Context) error { return nil } // --- metric exporter --- type logMetricExporter struct{ logger logz.Logger } func (e *logMetricExporter) Temporality(_ sdkmetric.InstrumentKind) metricdata.Temporality { return metricdata.CumulativeTemporality } func (e *logMetricExporter) Aggregation(k sdkmetric.InstrumentKind) sdkmetric.Aggregation { return sdkmetric.DefaultAggregationSelector(k) } func (e *logMetricExporter) Export(_ context.Context, rm *metricdata.ResourceMetrics) error { for _, sm := range rm.ScopeMetrics { for _, m := range sm.Metrics { e.logMetric(m) } } return nil } func (e *logMetricExporter) logMetric(m metricdata.Metrics) { switch d := m.Data.(type) { case metricdata.Sum[int64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "sum", "value", dp.Value, "unit", m.Unit) } case metricdata.Sum[float64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "sum", "value", dp.Value, "unit", m.Unit) } case metricdata.Gauge[int64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "gauge", "value", dp.Value, "unit", m.Unit) } case metricdata.Gauge[float64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "gauge", "value", dp.Value, "unit", m.Unit) } case metricdata.Histogram[int64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "histogram", "count", dp.Count, "sum", dp.Sum, "unit", m.Unit) } case metricdata.Histogram[float64]: for _, dp := range d.DataPoints { e.logger.Info("otel: metric", "name", m.Name, "kind", "histogram", "count", dp.Count, "sum", dp.Sum, "unit", m.Unit) } } } func (e *logMetricExporter) ForceFlush(_ context.Context) error { return nil } func (e *logMetricExporter) Shutdown(_ context.Context) error { return nil } // --- log exporter --- type logLogExporter struct{ logger logz.Logger } func (e *logLogExporter) Export(_ context.Context, records []sdklog.Record) error { for _, r := range records { body := r.Body().AsString() sev := r.Severity() switch { case sev >= otellog.SeverityError: e.logger.Error("otel: log", nil, "body", body, "severity", r.SeverityText()) case sev >= otellog.SeverityWarn: e.logger.Warn("otel: log", "body", body, "severity", r.SeverityText()) default: e.logger.Info("otel: log", "body", body, "severity", r.SeverityText()) } } return nil } func (e *logLogExporter) ForceFlush(_ context.Context) error { return nil } func (e *logLogExporter) Shutdown(_ context.Context) error { return nil }