From 2a8a759191d39adcdfe4d7a0318a25932df1837c Mon Sep 17 00:00:00 2001 From: Dylan Myers Date: Thu, 6 Aug 2026 09:20:15 -0400 Subject: [PATCH] feat(o11y): self-logs via shared entry-point zap->OTel bridge + OTLP log export (PIPE-1066) Assisted-by: Claude Opus 4.8 --- cmd/blitz/main.go | 48 +++++++---- config/loader.go | 5 ++ docs/embed.md | 80 +++++++++++++----- embed/host.go | 35 +++----- embed/integration_test.go | 116 +++++++++++++++++++++++++-- embed/new.go | 12 +-- embed/new_race_test.go | 4 +- embed/telemetry.go | 31 ++++++- embed/telemetry_test.go | 10 +++ go.mod | 33 ++++---- go.sum | 76 ++++++++++-------- internal/config/override.go | 2 + internal/config/override_test.go | 12 +++ internal/config/telemetry.go | 16 ++++ internal/telemetry/logs/logs.go | 100 +++++++++++++++++++++++ internal/telemetry/logs/logs_test.go | 101 +++++++++++++++++++++++ package/completions/blitz.bash | 9 +++ 17 files changed, 571 insertions(+), 119 deletions(-) create mode 100644 internal/telemetry/logs/logs.go create mode 100644 internal/telemetry/logs/logs_test.go diff --git a/cmd/blitz/main.go b/cmd/blitz/main.go index 1c97659..d67d696 100644 --- a/cmd/blitz/main.go +++ b/cmd/blitz/main.go @@ -24,6 +24,7 @@ import ( "github.com/observiq/blitz/internal/dispatch" "github.com/observiq/blitz/internal/logging" "github.com/observiq/blitz/internal/service" + "github.com/observiq/blitz/internal/telemetry/logs" "github.com/observiq/blitz/internal/telemetry/metrics" "github.com/observiq/blitz/internal/telemetry/traces" "github.com/observiq/blitz/output" @@ -120,16 +121,42 @@ func run(cmd *cobra.Command, args []string) error { } defer func() { _ = logger.Sync() }() + // Create signal context for graceful shutdown. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Build blitz's self-telemetry bundle. The log provider is constructed and + // the logger bridged FIRST, before any startup logging, so every line from + // here on is exported when log export is enabled. blitz bridges the logger + // once, here at the process entry point, and shares the bridged logger with + // every component; components do not re-bridge, since a bridged zap logger + // propagates to the child loggers they derive. Metrics leave the provider + // nil so they fall back to the process-global Prometheus provider that + // setupMetrics installs. + tel := embed.TelemetrySettings{PerBatchSpans: cfg.Telemetry.Traces.PerBatchSpans} + logExportEnabled := cfg.Telemetry.Logs.OTLPEndpoint != "" + if logExportEnabled { + otlpLogs, lerr := logs.NewOTLP(ctx, cfg.Telemetry.Logs.OTLPEndpoint, cfg.Telemetry.Logs.Insecure) + if lerr != nil { + logger.Error("Failed to enable self-telemetry log export", zap.Error(lerr)) + return lerr + } + defer func() { _ = otlpLogs.Shutdown(context.Background()) }() + tel.LoggerProvider = otlpLogs.Provider() + } + logger = tel.BridgedLogger(logger) + tel.Logger = logger + logger.Info("blitz started") + if logExportEnabled { + logger.Info("self-telemetry log export enabled", + zap.String("endpoint", cfg.Telemetry.Logs.OTLPEndpoint)) + } // Emit Warn-level banners for any deprecated generator types // configured by the user. Fires once per startup, not per record. config.LogGeneratorDeprecations(logger, cfg) - // Create signal context for graceful shutdown - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if err := setupMetrics(ctx, cfg, logger); err != nil { logger.Error("Failed to setup metrics", zap.Error(err)) return err @@ -144,16 +171,9 @@ func run(cmd *cobra.Command, args []string) error { cancel() }() - // Blitz routes its own self-telemetry through this bundle. Metrics leave - // the provider nil so they fall back to the process-global provider - // configured by setupMetrics (Prometheus). Trace export is opt-in via the - // telemetry.traces config: when an OTLP endpoint is set, spans export - // there; otherwise the nil TracerProvider means spans are created but - // dropped by the global no-op provider. - tel := embed.TelemetrySettings{ - Logger: logger, - PerBatchSpans: cfg.Telemetry.Traces.PerBatchSpans, - } + // Trace export is opt-in via the telemetry.traces config: when an OTLP + // endpoint is set, spans export there; otherwise the nil TracerProvider + // means spans are created but dropped by the global no-op provider. if cfg.Telemetry.Traces.OTLPEndpoint != "" { otlpTraces, terr := traces.NewOTLP(ctx, cfg.Telemetry.Traces.OTLPEndpoint, cfg.Telemetry.Traces.Insecure) if terr != nil { diff --git a/config/loader.go b/config/loader.go index 9ab5eb9..b9cd3f3 100644 --- a/config/loader.go +++ b/config/loader.go @@ -149,6 +149,11 @@ func LoadModules(yamlBytes []byte, opts EmbedOpts) ([]embed.ProducerModule, erro if logger == nil { logger = zap.NewNop() } + // Bridge the logger once here, at the embed construction entry point, and + // share the bridged logger with every generator. Components do not + // re-bridge: a bridged zap logger propagates to the child loggers they + // derive. A nil LoggerProvider leaves the logger zap-only. + logger = opts.Telemetry.BridgedLogger(logger) cfg, err := Load(yamlBytes, LoadOpts{EnvOverrides: opts.EnvOverrides}) if err != nil { return nil, err diff --git a/docs/embed.md b/docs/embed.md index f7f1ba7..a5351a0 100644 --- a/docs/embed.md +++ b/docs/embed.md @@ -81,38 +81,55 @@ import ( func main() { logger := zap.NewNop() - // 1. Host owns the consumers and ambient resources. + // 1. Build ONE self-telemetry bundle. Every field is optional and nil-safe. + // blitz builds its own metrics, spans, and log bridge from these. See + // "Self-telemetry" below. + tel := embed.TelemetrySettings{ + Logger: logger, // blitz's internal zap logger (nil -> nop) + MeterProvider: myMeterProvider, // nil -> process-global provider + TracerProvider: myTracerProvider, // nil -> process-global provider + LoggerProvider: myLoggerProvider, // nil -> logs stay zap-only (no bridge) + } + + // 2. Host owns the consumers, ambient resources, and the SAME bundle. host := embed.Host{ - Logs: myLogConsumer, - Metrics: myMetricConsumer, // optional, required when a metric generator is wired - Traces: myTraceConsumer, // optional, required when a trace generator is wired - Logger: logger, + Logs: myLogConsumer, + Metrics: myMetricConsumer, // optional, required when a metric generator is wired + Traces: myTraceConsumer, // optional, required when a trace generator is wired + Telemetry: tel, // Resource also available. } - // 2. Construct modules, passing the appropriate consumer from host. - apacheGen, _ := apache.New(logger, /*workers*/ 1, /*rate*/ time.Second, host.Logs) + // 3. Bridge the logger once, then construct modules. Components do NOT + // re-bridge: a bridged zap logger propagates to the child loggers they + // derive, so one bridge here covers them all. config.LoadModules does + // this for you on the YAML path. Each component builds its metrics and + // tracer from tel. + logger = tel.BridgedLogger(logger) + apacheGen, _ := apache.New(logger, /*workers*/ 1, /*rate*/ time.Second, host.Logs, tel) hmGen, _ := hostmetrics.New(hostmetrics.Config{ - Logger: logger, - Workers: 1, - Rate: 10 * time.Second, - OS: "linux", - Consumer: host.Metrics, + Logger: logger, + Workers: 1, + Rate: 10 * time.Second, + OS: "linux", + Consumer: host.Metrics, + Telemetry: tel, }) tracesGen, _ := traces.New(traces.Config{ - Logger: logger, - Workers: 1, - Rate: time.Second, - Consumer: host.Traces, + Logger: logger, + Workers: 1, + Rate: time.Second, + Consumer: host.Traces, + Telemetry: tel, }) - // 3. Build the runner. + // 4. Build the runner. runner, err := embed.New(embed.Config{ Modules: []embed.ProducerModule{apacheGen, hmGen, tracesGen}, }) if err != nil { /* ... */ } - // 4. Start, run, stop. + // 5. Start, run, stop. ctx := context.Background() if err := runner.Start(ctx, host); err != nil { /* ... */ } @@ -152,6 +169,32 @@ embed.Config{ Consumer errors are best-effort: blitz logs the error, increments a `consumer_errors` counter, and continues producing. A consumer that wants stricter semantics can return errors and observe them on the metric. +## Self-telemetry (logs, metrics, traces) + +Blitz emits its **own** operational telemetry (distinct from the data it generates), and an embedding host can route all three signals through host-supplied OTel providers. This is separate from the consumers above: consumers receive the generated data; the providers below receive blitz's internal observability. + +One `embed.TelemetrySettings` bundle carries everything: + +```go +type TelemetrySettings struct { + Logger *zap.Logger // blitz's internal diagnostic logger + MeterProvider metric.MeterProvider // source of blitz's metric instruments + TracerProvider trace.TracerProvider // source of blitz's spans + LoggerProvider log.LoggerProvider // receives blitz's logs as OTel records + PerBatchSpans bool // opt-in higher-volume per-emit-cycle spans +} +``` + +**One bundle, three signals.** The host builds a single bundle and supplies the *same value* in two places: as `embed.Host.Telemetry` (used by the runner for the session-level runtime) and at generator construction (the `Telemetry` field on config-struct constructors, or the trailing `tel` argument on positional ones, and `EmbedOpts.Telemetry` when using `config.LoadModules`). Metrics and traces are built per component from that bundle at construction; the logger is bridged once and shared: + +- **Metrics**: `output.NewMetrics(tel.MeterProvider)` / `generator.NewMetrics(...)`, per component. +- **Traces**: `tel.Tracer(scope)`, per component (plus the runtime's session spans). +- **Logs**: `tel.BridgedLogger(logger)` tees zap logging into `tel.LoggerProvider` as OTel records, done **once** at the entry point (`main` for standalone; `config.LoadModules` and the runner for embed) and shared. Components receive the already-bridged logger and do not re-bridge, since a bridged zap logger propagates to the child loggers they derive. A caller constructing a generator directly, bypassing `config.LoadModules`, bridges the logger itself. This is the OTel-idiomatic path: zap stays the logging API, the OTel Logs SDK is the bridge backend. + +**Nil-safe fallbacks.** Every field is optional. A nil `MeterProvider` or `TracerProvider` falls back to the process-global provider; a nil `LoggerProvider` leaves logs zap-only (no bridge). A zero-value bundle therefore behaves exactly as blitz did before providers were injectable, and `embed.NopTelemetry()` returns an all-no-op bundle for hosts that route nothing. + +**What blitz emits:** a `blitz.session` root span covering Start to Stop with a `blitz.generator.run` child span per module; per-generator and per-output metric instruments (`blitz.generator.entries`, and the rest); and blitz's internal zap logs, bridged to OTel when a `LoggerProvider` is set. + ## Resource attributes Every blitz record carries a per-record `Metadata.Resource` map describing the entity that emitted it (host, module, format, version). The three signal types are structured identically: @@ -233,6 +276,7 @@ Two supported paths: Logger: logger, LogConsumer: myLogConsumer, TraceConsumer: myTraceConsumer, + Telemetry: tel, // same bundle as embed.Host.Telemetry; see "Self-telemetry" // MetricConsumer also available. // FileGenLibrary: embeddedlibrary.FS(), // optional; nil = ./data_library/ on disk }) diff --git a/embed/host.go b/embed/host.go index 451b54f..39450e2 100644 --- a/embed/host.go +++ b/embed/host.go @@ -1,11 +1,5 @@ package embed -import ( - "go.opentelemetry.io/otel/metric" - "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" -) - // Host is the bundle of consumers and ambient resources a host process // supplies to an embedded blitz runner. // @@ -22,10 +16,6 @@ type Host struct { // Traces is the destination for spans. Nil means spans are dropped. Traces TraceConsumer - // Logger is the zap logger blitz uses for internal diagnostics. Nil - // means blitz constructs a no-op logger. - Logger *zap.Logger - // Resource is the per-session base resource attributes blitz applies // to every emitted record before module-level overrides merge on top. // @@ -36,20 +26,17 @@ type Host struct { // See cloneResource in this package. Resource map[string]string - // MeterProvider is the OTel MeterProvider blitz routes its own internal - // metrics through (generator and output self-telemetry). Nil falls back - // to the process-global provider, matching standalone behavior. - MeterProvider metric.MeterProvider - - // TracerProvider is the OTel TracerProvider blitz routes its own internal - // spans through. Nil falls back to the process-global provider. Reserved - // for the self-tracing phase; blitz emits no internal spans yet. - TracerProvider trace.TracerProvider - - // PerBatchSpans enables the higher-volume per-emit-cycle spans once - // self-tracing lands. Off by default; the coarse spans do not depend on - // it. - PerBatchSpans bool + // Telemetry carries the OTel providers and logger blitz routes its own + // self-telemetry through: the zap Logger for internal diagnostics, and the + // Meter, Tracer, and Logger providers blitz builds its metrics, spans, and + // log bridge from. It is the single source for all three signals. + // + // The host builds one bundle and supplies the same value here (used by the + // runner for the session-level runtime) and as EmbedOpts.Telemetry (used at + // generator construction). Every field is optional with a nil-safe + // fallback, so a zero-value bundle behaves as blitz did before providers + // were injectable. See TelemetrySettings. + Telemetry TelemetrySettings } // cloneResource returns a defensive copy of m. Runner.Start uses it so diff --git a/embed/integration_test.go b/embed/integration_test.go index 67c85fd..e8b871d 100644 --- a/embed/integration_test.go +++ b/embed/integration_test.go @@ -12,6 +12,11 @@ import ( "github.com/observiq/blitz/generator/traces" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/log/logtest" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" "go.uber.org/zap/zaptest" ) @@ -76,8 +81,8 @@ func TestEmbed_ApacheRecordsFlowToMemoryConsumer(t *testing.T) { require.NoError(t, err) host := embed.Host{ - Logs: consumer, - Logger: logger, + Logs: consumer, + Telemetry: embed.TelemetrySettings{Logger: logger}, } require.NoError(t, runner.Start(context.Background(), host)) @@ -121,8 +126,8 @@ func TestEmbed_TracesSpansFlowToMemoryConsumer(t *testing.T) { require.NoError(t, err) host := embed.Host{ - Traces: traceConsumer, - Logger: logger, + Traces: traceConsumer, + Telemetry: embed.TelemetrySettings{Logger: logger}, } require.NoError(t, runner.Start(context.Background(), host)) @@ -206,8 +211,8 @@ func TestEmbed_HostMetricsPointsFlowToMemoryConsumer(t *testing.T) { require.NoError(t, err) host := embed.Host{ - Metrics: consumer, - Logger: logger, + Metrics: consumer, + Telemetry: embed.TelemetrySettings{Logger: logger}, } require.NoError(t, runner.Start(context.Background(), host)) @@ -238,7 +243,7 @@ func TestEmbed_RunnerRejectsDoubleStart(t *testing.T) { runner, err := embed.New(embed.Config{Modules: []embed.ProducerModule{gen}}) require.NoError(t, err) - host := embed.Host{Logs: consumer, Logger: logger} + host := embed.Host{Logs: consumer, Telemetry: embed.TelemetrySettings{Logger: logger}} require.NoError(t, runner.Start(context.Background(), host)) defer func() { stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -250,3 +255,100 @@ func TestEmbed_RunnerRejectsDoubleStart(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "already started") } + +// hasMetric reports whether rm contains a metric with the given name in any +// scope. +func hasMetric(rm metricdata.ResourceMetrics, name string) bool { + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + if m.Name == name { + return true + } + } + } + return false +} + +// TestEmbed_HostBundleReceivesAllThreeSelfSignals is the C1 capstone: a host +// supplies one TelemetrySettings bundle with in-memory Meter, Tracer, and +// Logger providers, and blitz's own self-telemetry for all three signals lands +// in those providers in-process. It proves the uniform spine — every component +// builds metrics, spans, and its log bridge from the same bundle. The existing +// tests above, which run with a bundle whose providers are nil (only Logger +// set), are the paired "nil behaves as standalone" case: records still flow and +// nothing is emitted to OTel. +func TestEmbed_HostBundleReceivesAllThreeSelfSignals(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + spanRec := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRec)) + logRec := logtest.NewRecorder() + + base := zaptest.NewLogger(t) + // One bundle, supplied to both construction and the runner. + tel := embed.TelemetrySettings{ + Logger: base, + MeterProvider: mp, + TracerProvider: tp, + LoggerProvider: logRec, + } + + consumer := &memoryLogConsumer{} + // The generator builds its metrics from tel.MeterProvider. A caller that + // constructs a generator directly (rather than via config.LoadModules) + // bridges the logger itself, exactly as LoadModules does; blitz shares one + // bridged logger rather than re-bridging per component. + gen, err := apache.New(tel.BridgedLogger(base), 1, 10*time.Millisecond, consumer, tel) + require.NoError(t, err) + + runner, err := embed.New(embed.Config{Modules: []embed.ProducerModule{gen}}) + require.NoError(t, err) + + // Host carries the same bundle: the runner emits the blitz.session span + // through tel.TracerProvider and bridges the runtime logger into + // tel.LoggerProvider. + host := embed.Host{Logs: consumer, Telemetry: tel} + require.NoError(t, runner.Start(context.Background(), host)) + + require.Eventually(t, + func() bool { return len(consumer.snapshot()) >= 3 }, + 2*time.Second, 20*time.Millisecond, + "expected records to flow through the embed seam", + ) + + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, runner.Stop(stopCtx)) + + // Traces: the runtime emitted its session and generator-run spans through + // the host's TracerProvider. + var sessionSpans, genSpans int + for _, s := range spanRec.Ended() { + switch s.Name() { + case "blitz.session": + sessionSpans++ + case "blitz.generator.run": + genSpans++ + } + } + assert.GreaterOrEqual(t, sessionSpans, 1, "expected a blitz.session span in the host TracerProvider") + assert.GreaterOrEqual(t, genSpans, 1, "expected a blitz.generator.run span in the host TracerProvider") + + // Metrics: the generator recorded its self-metrics through the host's + // MeterProvider. + var rm metricdata.ResourceMetrics + require.NoError(t, reader.Collect(context.Background(), &rm)) + assert.True(t, hasMetric(rm, "blitz.generator.entries"), + "expected blitz.generator.entries in the host MeterProvider") + + // Logs: the generator's internal zap logging was bridged into the host's + // LoggerProvider as OTel records. + var logBodies []string + for _, records := range logRec.Result() { + for _, r := range records { + logBodies = append(logBodies, r.Body.AsString()) + } + } + assert.Contains(t, logBodies, "Starting Apache log generator", + "expected blitz's internal log bridged into the host LoggerProvider") +} diff --git a/embed/new.go b/embed/new.go index 802046b..4f6492c 100644 --- a/embed/new.go +++ b/embed/new.go @@ -6,7 +6,6 @@ import ( "sync" "github.com/observiq/blitz/internal/runtime" - "go.uber.org/zap" ) // New constructs a Runner that operates the configured ProducerModules. @@ -46,15 +45,16 @@ func (r *runner) Start(ctx context.Context, host Host) error { if r.started { return errors.New("embed runner already started") } - logger := host.Logger - if logger == nil { - logger = zap.NewNop() - } + // Bridge the runtime logger into the host's LoggerProvider the same way + // every generator does at construction, so session-level logs reach the + // host as OTel records too. A nil provider yields a plain zap logger. + tel := host.Telemetry + logger := tel.BridgedLogger(tel.Logger) rtModules := make([]runtime.Module, len(r.cfg.Modules)) for i, m := range r.cfg.Modules { rtModules[i] = m } - rt := runtime.New(logger, rtModules, host.TracerProvider) + rt := runtime.New(logger, rtModules, tel.TracerProvider) if err := rt.Start(ctx); err != nil { return err } diff --git a/embed/new_race_test.go b/embed/new_race_test.go index 1b91071..d9d35c1 100644 --- a/embed/new_race_test.go +++ b/embed/new_race_test.go @@ -30,7 +30,7 @@ func TestRunner_ConcurrentStartIsSerialized(t *testing.T) { runner, err := embed.New(embed.Config{Modules: []embed.ProducerModule{gen}}) require.NoError(t, err) - host := embed.Host{Logs: consumer, Logger: logger} + host := embed.Host{Logs: consumer, Telemetry: embed.TelemetrySettings{Logger: logger}} const goroutines = 8 var wg sync.WaitGroup @@ -78,7 +78,7 @@ func TestRunner_ConcurrentStopIsIdempotent(t *testing.T) { runner, err := embed.New(embed.Config{Modules: []embed.ProducerModule{gen}}) require.NoError(t, err) - host := embed.Host{Logs: consumer, Logger: logger} + host := embed.Host{Logs: consumer, Telemetry: embed.TelemetrySettings{Logger: logger}} require.NoError(t, runner.Start(context.Background(), host)) const goroutines = 8 diff --git a/embed/telemetry.go b/embed/telemetry.go index 29b887a..c16ae69 100644 --- a/embed/telemetry.go +++ b/embed/telemetry.go @@ -1,7 +1,9 @@ package embed import ( + "github.com/observiq/blitz/internal/telemetry/logs" "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/log" "go.opentelemetry.io/otel/metric" metricnoop "go.opentelemetry.io/otel/metric/noop" "go.opentelemetry.io/otel/trace" @@ -31,6 +33,11 @@ type TelemetrySettings struct { // otel.GetTracerProvider(). TracerProvider trace.TracerProvider + // LoggerProvider receives blitz's internal logs as OTel log records + // (bridged from zap). Nil means logs stay zap-only, matching the behavior + // before OTel logs were supported. + LoggerProvider log.LoggerProvider + // PerBatchSpans enables the higher-volume per-emit-cycle spans. Off by // default; the always-on coarse spans do not depend on it. PerBatchSpans bool @@ -47,11 +54,31 @@ func (t TelemetrySettings) Tracer(scope string) trace.Tracer { return tp.Tracer(scope) } +// BridgedLogger returns base teed into the bundle's LoggerProvider, so blitz's +// zap logging is also emitted as OTel log records. Unlike metrics and traces, +// which are built per component, the logger is bridged ONCE at the process +// entry point (main for standalone; config.LoadModules and the runner for +// embed) and the bridged logger is shared: a bridged zap logger propagates to +// the child loggers components derive, so re-bridging per component would only +// duplicate records. A nil base yields a nop logger; a nil LoggerProvider +// returns base unchanged (zap only), so the result is always safe to use. +func (t TelemetrySettings) BridgedLogger(base *zap.Logger) *zap.Logger { + if base == nil { + base = zap.NewNop() + } + return logs.BridgeZap(base, t.LoggerProvider) +} + // NopTelemetry returns a TelemetrySettings wired to no-op providers and a nop // logger. Use it where a caller has no telemetry to route, most commonly in // tests and in construction paths that record nothing. It is distinct from a -// zero-value bundle: NopTelemetry emits to no-op providers, while a zero-value -// bundle's nil fields fall back to the process globals. +// zero-value bundle: NopTelemetry emits to no-op meter and tracer providers, +// while a zero-value bundle's nil fields fall back to the process globals. +// +// LoggerProvider is left nil: for logs, "no-op" means no bridge at all, so +// BridgedLogger returns the base logger unchanged (zap only) rather than teeing +// into a no-op provider. That keeps the nop path zero-overhead and preserves +// logger identity for callers that compare loggers. func NopTelemetry() TelemetrySettings { return TelemetrySettings{ Logger: zap.NewNop(), diff --git a/embed/telemetry_test.go b/embed/telemetry_test.go index 562a6f5..c74306f 100644 --- a/embed/telemetry_test.go +++ b/embed/telemetry_test.go @@ -53,3 +53,13 @@ func TestTelemetrySettings_Tracer_nilFallsBackToGlobal(t *testing.T) { span.End() }) } + +func TestTelemetrySettings_BridgedLogger_nilBase(t *testing.T) { + // A nil base logger yields a usable nop logger, and a nil LoggerProvider + // leaves it zap-only. The result is always safe to use. + var tel TelemetrySettings + + l := tel.BridgedLogger(nil) + require.NotNil(t, l) + require.NotPanics(t, func() { l.Info("safe") }) +} diff --git a/go.mod b/go.mod index 8137e23..74d0339 100644 --- a/go.mod +++ b/go.mod @@ -19,14 +19,19 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 - go.opentelemetry.io/otel/exporters/prometheus v0.66.0 - go.opentelemetry.io/otel/metric v1.44.0 - go.opentelemetry.io/otel/sdk v1.44.0 - go.opentelemetry.io/otel/sdk/metric v1.44.0 - go.opentelemetry.io/otel/trace v1.44.0 + go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 + go.opentelemetry.io/otel v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 + go.opentelemetry.io/otel/exporters/prometheus v0.67.0 + go.opentelemetry.io/otel/log v0.21.0 + go.opentelemetry.io/otel/log/logtest v0.21.0 + go.opentelemetry.io/otel/metric v1.45.0 + go.opentelemetry.io/otel/sdk v1.45.0 + go.opentelemetry.io/otel/sdk/log v0.21.0 + go.opentelemetry.io/otel/sdk/metric v1.45.0 + go.opentelemetry.io/otel/trace v1.45.0 go.opentelemetry.io/proto/otlp v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/sys v0.47.0 @@ -50,9 +55,9 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.18.0 // indirect github.com/fatih/structtag v1.2.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -89,8 +94,8 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.uber.org/multierr v1.10.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.54.0 // indirect golang.org/x/mod v0.37.0 // indirect @@ -99,7 +104,7 @@ require ( golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.47.0 // indirect google.golang.org/genai v1.37.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4e4a2c8..97ad676 100644 --- a/go.sum +++ b/go.sum @@ -31,15 +31,15 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= @@ -114,8 +114,8 @@ github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEo github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -157,32 +157,44 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= -go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= -go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ= -go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o= -go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= -go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= -go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= -go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= -go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= -go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0 h1:wgsHT2HLf1KEZtCkd6ZGynPdeIyFsCSKsHyDBW9vEJk= +go.opentelemetry.io/contrib/bridges/otelzap v0.20.0/go.mod h1:NZCU/Hi3EdSS6LxcNJOO39Y5z95R9QY5iQ2Ym6IOFTU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU= +go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0 h1:WseeVYf5dJZTsyPiyW5L14k5qsSibqXAMTSiFEDiWr0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.21.0/go.mod h1:SiLZnQS6Qk2eCpvr2CH/XMAOa64TWGXxEZJZCpD2Lmc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 h1:QRefszxJmfPdjXUUm3j6iDzY03mTPXMjqErFqQ67vUg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0/go.mod h1:Tiz03lTBVBrm7eWZBOidzEaYaJa8tjwGUGv6d8mlTyk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MCxGz8+2VtrN/WgqSpJFctVz24gpxj8CxkKmc8Ww= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0 h1:7IefDa35e6V3NoiqIeLDMDxMFyZDk5qcoC0Ax4cC16E= +go.opentelemetry.io/otel/exporters/prometheus v0.67.0/go.mod h1:nsPI1awTg5Vmg1YrommL2mVarVGlqc4yXOoKAkPRD0c= +go.opentelemetry.io/otel/log v0.21.0 h1:SLsVDGmtyBrdw8/a2Z0bOIxou/+bN4z56GebH7T0LvA= +go.opentelemetry.io/otel/log v0.21.0/go.mod h1:iReetQrZL9Wyg84cCkOoCmqDHS5RCFfyxC7J+r8fn8g= +go.opentelemetry.io/otel/log/logtest v0.21.0 h1:/Zr/0DoraAjiX91pZMn72uSDkd7hA+jn3CPU2y+2rWY= +go.opentelemetry.io/otel/log/logtest v0.21.0/go.mod h1:dyswW/l7aXiiCAmbKlt+Eg2NUnN6p1YrHQPBiV7QrLU= +go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M= +go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s= +go.opentelemetry.io/otel/metric/x v0.67.0 h1:PcicCNZFkZ4bXfSooXdo3WN7RBOVOtjVdo1wD358Uns= +go.opentelemetry.io/otel/metric/x v0.67.0/go.mod h1:FBjCWZe6wgcqxcMtjdGiClDKXb2YxxXii0CXftE4QtI= +go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw= +go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA= +go.opentelemetry.io/otel/sdk/log v0.21.0 h1:QsE7XSR0ktQdKmRKGnR+f1ObGF32WG+7MER/P9KgmYc= +go.opentelemetry.io/otel/sdk/log v0.21.0/go.mod h1:m9mApjCoD2/1QuKCAptjv+BrG9WKOvQLVdNx+iBldTo= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0 h1:X+JBBgKlswCGYsmgL0CnoUUtlE//VB345c84jYAYkdQ= +go.opentelemetry.io/otel/sdk/log/logtest v0.21.0/go.mod h1:HD1575K8e6sIFBBDd5tZB3t9DlMytWXq9FuR+Y4rfjE= +go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= +go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag= +go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= @@ -210,10 +222,10 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genai v1.37.0 h1:dgp71k1wQ+/+APdZrN3LFgAGnVnr5IdTF1Oj0Dg+BQc= google.golang.org/genai v1.37.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg= -google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc= +google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/config/override.go b/internal/config/override.go index 1153206..e059418 100644 --- a/internal/config/override.go +++ b/internal/config/override.go @@ -341,6 +341,8 @@ func DefaultOverrides() []*Override { NewOverride("telemetry.traces.otlpEndpoint", "OTLP gRPC endpoint (host:port) for exporting blitz's own spans (empty = disabled)", ""), NewOverride("telemetry.traces.insecure", "send blitz's own spans over plaintext gRPC (no TLS)", false), NewOverride("telemetry.traces.perBatchSpans", "enable higher-volume per-emit-cycle spans (off by default)", false), + NewOverride("telemetry.logs.otlpEndpoint", "OTLP gRPC endpoint (host:port) for exporting blitz's own logs (empty = disabled)", ""), + NewOverride("telemetry.logs.insecure", "send blitz's own logs over plaintext gRPC (no TLS)", false), } overrides = append(overrides, tcpTLSOverrides()...) diff --git a/internal/config/override_test.go b/internal/config/override_test.go index 3907a16..a81ae05 100644 --- a/internal/config/override_test.go +++ b/internal/config/override_test.go @@ -143,6 +143,8 @@ func getTestOverrideFlagsArgs() []string { "--telemetry-traces-otlpendpoint", "traces.example:4317", "--telemetry-traces-insecure", "true", "--telemetry-traces-perbatchspans", "true", + "--telemetry-logs-otlpendpoint", "logs.example:4317", + "--telemetry-logs-insecure", "true", } } @@ -278,6 +280,8 @@ func getTestOverrideEnvs() map[string]string { "BLITZ_TELEMETRY_TRACES_OTLPENDPOINT": "traces.env.example:4317", "BLITZ_TELEMETRY_TRACES_INSECURE": "true", "BLITZ_TELEMETRY_TRACES_PERBATCHSPANS": "true", + "BLITZ_TELEMETRY_LOGS_OTLPENDPOINT": "logs.env.example:4317", + "BLITZ_TELEMETRY_LOGS_INSECURE": "true", } } @@ -689,6 +693,10 @@ func TestOverrideFlags(t *testing.T) { Insecure: true, PerBatchSpans: true, }, + Logs: LogsTelemetry{ + OTLPEndpoint: "logs.example:4317", + Insecure: true, + }, }, } require.Equal(t, expectedCfg, cfg) @@ -905,6 +913,10 @@ func TestOverrideEnvs(t *testing.T) { Insecure: true, PerBatchSpans: true, }, + Logs: LogsTelemetry{ + OTLPEndpoint: "logs.env.example:4317", + Insecure: true, + }, }, } require.Equal(t, expectedCfg, cfg) diff --git a/internal/config/telemetry.go b/internal/config/telemetry.go index b1796ff..2f515a5 100644 --- a/internal/config/telemetry.go +++ b/internal/config/telemetry.go @@ -8,6 +8,22 @@ package config type Telemetry struct { // Traces configures OTLP export of blitz's internal spans. Traces TracesTelemetry `yaml:"traces,omitempty" mapstructure:"traces,omitempty"` + + // Logs configures OTLP export of blitz's internal logs. + Logs LogsTelemetry `yaml:"logs,omitempty" mapstructure:"logs,omitempty"` +} + +// LogsTelemetry configures OTLP gRPC export of blitz's internal logs. When an +// endpoint is set, blitz's internal zap logging is bridged into an OTel +// LoggerProvider and exported alongside the usual zap output. +type LogsTelemetry struct { + // OTLPEndpoint is the OTLP gRPC endpoint (host:port). Empty disables log + // export: blitz logs stay zap-only, matching the behavior before OTel logs + // were supported. + OTLPEndpoint string `yaml:"otlpEndpoint,omitempty" mapstructure:"otlpEndpoint,omitempty"` + + // Insecure sends logs over plaintext gRPC (no TLS). Defaults to false. + Insecure bool `yaml:"insecure,omitempty" mapstructure:"insecure,omitempty"` } // TracesTelemetry configures OTLP gRPC export of blitz's internal spans. diff --git a/internal/telemetry/logs/logs.go b/internal/telemetry/logs/logs.go new file mode 100644 index 0000000..418eb42 --- /dev/null +++ b/internal/telemetry/logs/logs.go @@ -0,0 +1,100 @@ +// Package logs provides an OTLP gRPC exporter and LoggerProvider for blitz's +// own self-telemetry logs, plus a zap bridge that tees blitz's internal zap +// logging into an OTel LoggerProvider. It mirrors the trace setup in +// internal/telemetry/traces: a thin wrapper around the OTel SDK that the +// standalone CLI wires up when log export is configured. +package logs + +import ( + "context" + "fmt" + "os" + + "go.opentelemetry.io/contrib/bridges/otelzap" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.4.0" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +const ( + serviceName = "blitz" + // scopeName is the instrumentation scope for blitz's internal logs, + // matching the module path used by the traces self-telemetry. + scopeName = "github.com/observiq/blitz" +) + +// osHostname and newLogExporter are indirections over os.Hostname and the OTLP +// log exporter constructor, so tests can exercise the hostname-fallback and +// exporter-error paths deterministically. +var ( + osHostname = os.Hostname + newLogExporter = otlploggrpc.New +) + +// OTLP owns an OTLP gRPC log exporter and the LoggerProvider built on it. +type OTLP struct { + provider *sdklog.LoggerProvider +} + +// NewOTLP builds a batching LoggerProvider that exports blitz's self-telemetry +// logs over OTLP gRPC to endpoint (host:port). insecure sends over plaintext. +// The exporter connects lazily, so a nil error does not imply the collector is +// reachable. Unlike the trace provider, this is not installed as a process +// global: there is no global LoggerProvider to set, so the returned provider is +// threaded explicitly via BridgeZap. +func NewOTLP(ctx context.Context, endpoint string, insecure bool) (*OTLP, error) { + hostname, err := osHostname() + if err != nil { + hostname = "unknown" + } + res := resource.NewWithAttributes(semconv.SchemaURL, + semconv.ServiceNameKey.String(serviceName), + semconv.HostNameKey.String(hostname), + ) + + opts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(endpoint)} + if insecure { + opts = append(opts, otlploggrpc.WithInsecure()) + } + exporter, err := newLogExporter(ctx, opts...) + if err != nil { + return nil, fmt.Errorf("create otlp log exporter: %w", err) + } + + provider := sdklog.NewLoggerProvider( + sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), + sdklog.WithResource(res), + ) + + return &OTLP{provider: provider}, nil +} + +// Provider returns the LoggerProvider for wiring into a TelemetrySettings and +// bridging via BridgeZap. +func (o *OTLP) Provider() log.LoggerProvider { return o.provider } + +// Shutdown flushes buffered log records and stops the exporter. +func (o *OTLP) Shutdown(ctx context.Context) error { + if o.provider != nil { + return o.provider.Shutdown(ctx) + } + return nil +} + +// BridgeZap tees logger into lp so blitz's internal zap logs are also emitted as +// OTel log records. A nil lp returns logger unchanged (zap only, today's +// behavior). The returned logger writes to both the original zap core and an +// otelzap core backed by lp. +func BridgeZap(logger *zap.Logger, lp log.LoggerProvider) *zap.Logger { + if lp == nil { + return logger + } + otelCore := otelzap.NewCore(scopeName, otelzap.WithLoggerProvider(lp)) + return logger.WithOptions(zap.WrapCore(func(existing zapcore.Core) zapcore.Core { + return zapcore.NewTee(existing, otelCore) + })) +} diff --git a/internal/telemetry/logs/logs_test.go b/internal/telemetry/logs/logs_test.go new file mode 100644 index 0000000..d910a07 --- /dev/null +++ b/internal/telemetry/logs/logs_test.go @@ -0,0 +1,101 @@ +package logs + +import ( + "context" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc" + "go.opentelemetry.io/otel/log/logtest" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// TestNewOTLP_buildsProviderAndShutsDown confirms the OTLP logger provider +// constructs without a live collector (the exporter connects lazily) and shuts +// down cleanly. It does not assert export, which would require a collector. +func TestNewOTLP_buildsProviderAndShutsDown(t *testing.T) { + o, err := NewOTLP(context.Background(), "localhost:4317", true) + require.NoError(t, err) + require.NotNil(t, o) + require.NotNil(t, o.Provider()) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + require.NoError(t, o.Shutdown(ctx)) +} + +// discardLogger returns a real zap logger at debug level that writes nowhere, +// so the tee has a live source core without polluting test output. +func discardLogger() *zap.Logger { + core := zapcore.NewCore( + zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), + zapcore.AddSync(io.Discard), + zapcore.DebugLevel, + ) + return zap.New(core) +} + +// TestBridgeZap_teesRecordsToLoggerProvider confirms a bridged logger emits an +// OTel log record to the supplied LoggerProvider while still logging via zap. +func TestBridgeZap_teesRecordsToLoggerProvider(t *testing.T) { + rec := logtest.NewRecorder() + + logger := BridgeZap(discardLogger(), rec) + logger.Info("hello from blitz") + + got := rec.Result() + require.NotEmpty(t, got, "expected at least one recorded scope") + + var bodies []string + for scope, records := range got { + require.Equal(t, scopeName, scope.Name) + for _, r := range records { + bodies = append(bodies, r.Body.AsString()) + } + } + require.Contains(t, bodies, "hello from blitz") +} + +// TestBridgeZap_nilProviderReturnsLoggerUnchanged confirms a nil LoggerProvider +// leaves the zap logger untouched (zap-only, today's behavior). +func TestBridgeZap_nilProviderReturnsLoggerUnchanged(t *testing.T) { + in := zap.NewNop() + out := BridgeZap(in, nil) + require.Same(t, in, out) +} + +// TestNewOTLP_hostnameFallback covers the os.Hostname error path: the provider +// still builds, falling back to an "unknown" hostname resource attribute. +func TestNewOTLP_hostnameFallback(t *testing.T) { + orig := osHostname + osHostname = func() (string, error) { return "", errors.New("no hostname") } + defer func() { osHostname = orig }() + + o, err := NewOTLP(context.Background(), "localhost:4317", true) + require.NoError(t, err) + require.NotNil(t, o) + require.NoError(t, o.Shutdown(context.Background())) +} + +// TestNewOTLP_exporterError covers the exporter-construction error path. +func TestNewOTLP_exporterError(t *testing.T) { + orig := newLogExporter + newLogExporter = func(context.Context, ...otlploggrpc.Option) (*otlploggrpc.Exporter, error) { + return nil, errors.New("boom") + } + defer func() { newLogExporter = orig }() + + _, err := NewOTLP(context.Background(), "localhost:4317", true) + require.Error(t, err) + require.Contains(t, err.Error(), "create otlp log exporter") +} + +// TestShutdown_nilProvider confirms Shutdown is safe on a zero-value OTLP. +func TestShutdown_nilProvider(t *testing.T) { + var o OTLP + require.NoError(t, o.Shutdown(context.Background())) +} diff --git a/package/completions/blitz.bash b/package/completions/blitz.bash index 3e59c0d..7e4e53b 100644 --- a/package/completions/blitz.bash +++ b/package/completions/blitz.bash @@ -632,6 +632,9 @@ _blitz_help() two_word_flags+=("--output-udp-port") flags+=("--output-udp-workers=") two_word_flags+=("--output-udp-workers") + flags+=("--telemetry-logs-insecure") + flags+=("--telemetry-logs-otlpendpoint=") + two_word_flags+=("--telemetry-logs-otlpendpoint") flags+=("--telemetry-traces-insecure") flags+=("--telemetry-traces-otlpendpoint=") two_word_flags+=("--telemetry-traces-otlpendpoint") @@ -915,6 +918,9 @@ _blitz_version() two_word_flags+=("--output-udp-port") flags+=("--output-udp-workers=") two_word_flags+=("--output-udp-workers") + flags+=("--telemetry-logs-insecure") + flags+=("--telemetry-logs-otlpendpoint=") + two_word_flags+=("--telemetry-logs-otlpendpoint") flags+=("--telemetry-traces-insecure") flags+=("--telemetry-traces-otlpendpoint=") two_word_flags+=("--telemetry-traces-otlpendpoint") @@ -1199,6 +1205,9 @@ _blitz_root_command() two_word_flags+=("--output-udp-port") flags+=("--output-udp-workers=") two_word_flags+=("--output-udp-workers") + flags+=("--telemetry-logs-insecure") + flags+=("--telemetry-logs-otlpendpoint=") + two_word_flags+=("--telemetry-logs-otlpendpoint") flags+=("--telemetry-traces-insecure") flags+=("--telemetry-traces-otlpendpoint=") two_word_flags+=("--telemetry-traces-otlpendpoint")