Files
telemetry/console.go

184 lines
5.9 KiB
Go
Raw Permalink Normal View History

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 }