feat(telemetry): add NewConsole — logz-backed OTel exporters for local dev (v1.1.0)
NewConsole bootstraps the OTel SDK with three logz-backed exporters: - Trace: WithSyncer, one log line per closed span (immediate, no batch) - Metric: PeriodicReader (60s), flushed on shutdown - OTel log: BatchProcessor, for third-party libs using OTel log API ConsoleConfig requires only ServiceName — no OTLP endpoint needed. Adds logz v1.0.1 as direct dependency; module tier bumped 1 → 2.
This commit is contained in:
183
console.go
Normal file
183
console.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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 }
|
||||
Reference in New Issue
Block a user