From c57d57b84f2824473311e75d252b3f669dfcc865 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 20 Jul 2026 18:11:13 -0400 Subject: [PATCH] SMOODEV: Add Go OTLP logs signal (ADR-100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the third OTLP pipeline alongside traces and metrics: an sdk/log LoggerProvider with a batch processor exporting to the product /v1/logs endpoint, plus an otelslog-backed slog.Handler (SlogHandler) so app logs become OTel log records correlated to the active span. Reuses the existing traces/metrics wiring verbatim — same SMOOAI_OBSERVABILITY_ENDPOINT base (logs → /v1/logs), same auth (static header or TokenProvider via buildHTTPClient), same resource, same enable/init gating. Graceful no-op when no logs endpoint is configured. Deps pinned to otel core v1.44.0: otel/log + sdk/log + otlploghttp v0.20.0, otelslog v0.19.0. No unrelated bumps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S2bM94GAnVjYSSv1x7HKRB --- go/bootstrap.go | 7 ++++ go/go.mod | 4 ++ go/go.sum | 8 ++++ go/logs.go | 60 ++++++++++++++++++++++++++++ go/logs_test.go | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ go/otel.go | 23 +++++++++++ 6 files changed, 203 insertions(+) create mode 100644 go/logs.go create mode 100644 go/logs_test.go diff --git a/go/bootstrap.go b/go/bootstrap.go index 941ef44..a3338d1 100644 --- a/go/bootstrap.go +++ b/go/bootstrap.go @@ -38,6 +38,7 @@ type BootstrapEnv struct { Endpoint string TracesEndpoint string MetricsEndpoint string + LogsEndpoint string Token string AuthURL string ClientID string @@ -116,6 +117,10 @@ func Bootstrap(ctx context.Context, overrides *BootstrapEnv) BootstrapResult { if metricEndpoint == "" && env.Endpoint != "" { metricEndpoint = strings.TrimRight(env.Endpoint, "/") + "/v1/metrics" } + logEndpoint := env.LogsEndpoint + if logEndpoint == "" && env.Endpoint != "" { + logEndpoint = strings.TrimRight(env.Endpoint, "/") + "/v1/logs" + } otelHandle := SetupOtelSDK(ctx, SetupOtelOptions{ ServiceName: env.ServiceName, @@ -123,6 +128,7 @@ func Bootstrap(ctx context.Context, overrides *BootstrapEnv) BootstrapResult { Release: env.Release, TracesEndpoint: traceEndpoint, MetricsEndpoint: metricEndpoint, + LogsEndpoint: logEndpoint, Headers: headers, TokenProvider: tokenProvider, }) @@ -163,6 +169,7 @@ func resolveEnv(o *BootstrapEnv) BootstrapEnv { Endpoint: pick(o.Endpoint, "SMOOAI_OBSERVABILITY_ENDPOINT"), TracesEndpoint: pick(o.TracesEndpoint, "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"), MetricsEndpoint: pick(o.MetricsEndpoint, "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"), + LogsEndpoint: pick(o.LogsEndpoint, "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"), Token: pick(o.Token, "SMOOAI_OBSERVABILITY_TOKEN"), AuthURL: pick(o.AuthURL, "SMOOAI_OBSERVABILITY_AUTH_URL"), ClientID: pick(o.ClientID, "SMOOAI_OBSERVABILITY_CLIENT_ID"), diff --git a/go/go.mod b/go/go.mod index b7b264a..30d496c 100644 --- a/go/go.mod +++ b/go/go.mod @@ -22,7 +22,11 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/sony/gobreaker/v2 v2.0.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/log v0.20.0 // indirect + go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.45.0 // indirect diff --git a/go/go.sum b/go/go.sum index 7794a8d..cefa57e 100644 --- a/go/go.sum +++ b/go/go.sum @@ -27,20 +27,28 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ= +go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY= 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/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= 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/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= +go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= 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/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY= +go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU= 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= diff --git a/go/logs.go b/go/logs.go new file mode 100644 index 0000000..a189731 --- /dev/null +++ b/go/logs.go @@ -0,0 +1,60 @@ +package observability + +import ( + "context" + "log/slog" + + "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + logglobal "go.opentelemetry.io/otel/log/global" + sdklog "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/resource" +) + +// Logs signal — the third OTLP pipeline alongside traces and metrics, feeding +// the product /v1/logs endpoint. Mirrors the traces/metrics setup exactly: same +// endpoint base, same per-request auth (static header or TokenProvider via +// buildHTTPClient), same resource. App logs reach it through an slog.Handler +// (SlogHandler), which the otelslog bridge maps into OTel log records — reading +// trace_id/span_id from the ACTIVE span context in each Handle(ctx, ...) call, +// so records correlate with the enclosing trace with no manual plumbing. +// +// Product column mapping: severity←record.Level, body←record.Message, +// trace_id/span_id←active span, resource service.name→service_name, +// attrs→parsed_fields. + +// buildLoggerProvider constructs an OTLP/HTTP LoggerProvider with a batch +// processor, or returns nil when no endpoint is configured (graceful no-op). +func buildLoggerProvider(ctx context.Context, opts SetupOtelOptions, logEndpoint string, res *resource.Resource) *sdklog.LoggerProvider { + if logEndpoint == "" { + return nil + } + logOpts := []otlploghttp.Option{otlploghttp.WithEndpointURL(logEndpoint)} + if len(opts.Headers) > 0 { + logOpts = append(logOpts, otlploghttp.WithHeaders(opts.Headers)) + } + if client := buildHTTPClient(opts); client != nil { + logOpts = append(logOpts, otlploghttp.WithHTTPClient(client)) + } + exp, err := otlploghttp.New(ctx, logOpts...) + if err != nil { + return nil + } + return sdklog.NewLoggerProvider( + sdklog.WithProcessor(sdklog.NewBatchProcessor(exp)), + sdklog.WithResource(res), + ) +} + +// SlogHandler returns an slog.Handler that emits through the global OTel +// LoggerProvider (installed by SetupOtelSDK). Wire it into an application's +// slog.Logger — or into @smooai/logger via logger.SetSlogHandler — so every log +// line becomes an OTLP log record correlated to the active span. When the logs +// signal is disabled the global provider is a no-op and the handler drops +// records silently, so it is always safe to install. +// +// name becomes the OTel InstrumentationScope name (the logger scope). Pass the +// service or component name; empty is accepted. +func SlogHandler(name string) slog.Handler { + return otelslog.NewHandler(name, otelslog.WithLoggerProvider(logglobal.GetLoggerProvider())) +} diff --git a/go/logs_test.go b/go/logs_test.go new file mode 100644 index 0000000..a1f2368 --- /dev/null +++ b/go/logs_test.go @@ -0,0 +1,101 @@ +package observability + +import ( + "context" + "log/slog" + "sync" + "testing" + + logglobal "go.opentelemetry.io/otel/log/global" + sdklog "go.opentelemetry.io/otel/sdk/log" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// capturingExporter records exported log records for assertions. +type capturingExporter struct { + mu sync.Mutex + records []sdklog.Record +} + +func (e *capturingExporter) Export(_ context.Context, records []sdklog.Record) error { + e.mu.Lock() + defer e.mu.Unlock() + e.records = append(e.records, records...) + return nil +} +func (e *capturingExporter) Shutdown(context.Context) error { return nil } +func (e *capturingExporter) ForceFlush(context.Context) error { return nil } + +func (e *capturingExporter) all() []sdklog.Record { + e.mu.Lock() + defer e.mu.Unlock() + return append([]sdklog.Record(nil), e.records...) +} + +// A log emitted through SlogHandler with an active span in ctx must carry that +// span's real W3C trace_id/span_id — the correlation contract for /v1/logs. +func TestSlogHandlerCorrelatesWithActiveSpan(t *testing.T) { + exp := &capturingExporter{} + lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewSimpleProcessor(exp))) + prev := logglobal.GetLoggerProvider() + logglobal.SetLoggerProvider(lp) + defer logglobal.SetLoggerProvider(prev) + + tp := sdktrace.NewTracerProvider() + ctx, span := tp.Tracer("t").Start(context.Background(), "op") + wantTrace := span.SpanContext().TraceID() + wantSpan := span.SpanContext().SpanID() + + slog.New(SlogHandler("test")).WarnContext(ctx, "hello", "k", "v") + span.End() + + recs := exp.all() + if len(recs) != 1 { + t.Fatalf("want 1 record, got %d", len(recs)) + } + r := recs[0] + if r.TraceID() != wantTrace { + t.Errorf("trace id = %s, want %s (active span not read)", r.TraceID(), wantTrace) + } + if r.SpanID() != wantSpan { + t.Errorf("span id = %s, want %s", r.SpanID(), wantSpan) + } + if r.Body().AsString() != "hello" { + t.Errorf("body = %q, want hello", r.Body().AsString()) + } + if r.Severity() != 0 && r.Severity() < 1 { + t.Errorf("severity not set from level: %v", r.Severity()) + } +} + +// No active span → no fabricated ids (zero trace/span), record still emitted. +func TestSlogHandlerNoSpanNoCorrelation(t *testing.T) { + exp := &capturingExporter{} + lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewSimpleProcessor(exp))) + prev := logglobal.GetLoggerProvider() + logglobal.SetLoggerProvider(lp) + defer logglobal.SetLoggerProvider(prev) + + slog.New(SlogHandler("test")).Info("no span here") + + recs := exp.all() + if len(recs) != 1 { + t.Fatalf("want 1 record, got %d", len(recs)) + } + if recs[0].TraceID().IsValid() { + t.Errorf("expected no trace id without active span, got %s", recs[0].TraceID()) + } +} + +// Disabled logs signal (no endpoint) → nil provider, graceful no-op. +func TestBuildLoggerProviderNoEndpointIsNoOp(t *testing.T) { + if lp := buildLoggerProvider(context.Background(), SetupOtelOptions{}, "", buildResource(SetupOtelOptions{})); lp != nil { + t.Error("expected nil LoggerProvider with no endpoint") + } + lp := buildLoggerProvider(context.Background(), SetupOtelOptions{}, "https://api.smoo.ai/v1/logs", buildResource(SetupOtelOptions{ServiceName: "svc"})) + if lp == nil { + t.Error("expected non-nil LoggerProvider when endpoint set") + } else { + _ = lp.Shutdown(context.Background()) + } +} diff --git a/go/otel.go b/go/otel.go index 31fa241..2753007 100644 --- a/go/otel.go +++ b/go/otel.go @@ -12,7 +12,9 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + logglobal "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/propagation" + sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/metric" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" @@ -46,6 +48,9 @@ type SetupOtelOptions struct { TracesEndpoint string // MetricsEndpoint is the full OTLP/HTTP metrics URL. Falls back to env. MetricsEndpoint string + // LogsEndpoint is the full OTLP/HTTP logs URL (e.g. + // https://api.smoo.ai/v1/logs). Falls back to env vars when empty. + LogsEndpoint string // Headers are static headers merged onto every export request. Headers map[string]string // TokenProvider, when set, injects a fresh Bearer per export request. @@ -63,6 +68,7 @@ type SetupOtelOptions struct { type OtelSDKHandle struct { TracerProvider *sdktrace.TracerProvider MeterProvider *metric.MeterProvider + LoggerProvider *sdklog.LoggerProvider } // Flush force-flushes spans and metrics, bounded by timeoutMs. Best-effort. @@ -79,6 +85,9 @@ func (h *OtelSDKHandle) Flush(ctx context.Context, timeout time.Duration) { if h.MeterProvider != nil { _ = h.MeterProvider.ForceFlush(fctx) } + if h.LoggerProvider != nil { + _ = h.LoggerProvider.ForceFlush(fctx) + } } // Shutdown drains and closes the pipelines. Idempotent. @@ -90,6 +99,9 @@ func (h *OtelSDKHandle) Shutdown(ctx context.Context) { if h.MeterProvider != nil { _ = h.MeterProvider.Shutdown(ctx) } + if h.LoggerProvider != nil { + _ = h.LoggerProvider.Shutdown(ctx) + } otelInstallMu.Lock() otelInstalled = nil otelInstallMu.Unlock() @@ -124,6 +136,9 @@ func SetupOtelSDK(ctx context.Context, opts SetupOtelOptions) *OtelSDKHandle { metricEndpoint := firstNonEmpty(opts.MetricsEndpoint, os.Getenv("OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"), os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) + logEndpoint := firstNonEmpty(opts.LogsEndpoint, + os.Getenv("OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"), + os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")) res := buildResource(opts) @@ -168,6 +183,11 @@ func SetupOtelSDK(ctx context.Context, opts SetupOtelOptions) *OtelSDKHandle { } } + // --- Logs --- + if lp := buildLoggerProvider(ctx, opts, logEndpoint, res); lp != nil { + handle.LoggerProvider = lp + } + if !opts.SkipStart { if handle.TracerProvider != nil { otel.SetTracerProvider(handle.TracerProvider) @@ -175,6 +195,9 @@ func SetupOtelSDK(ctx context.Context, opts SetupOtelOptions) *OtelSDKHandle { if handle.MeterProvider != nil { otel.SetMeterProvider(handle.MeterProvider) } + if handle.LoggerProvider != nil { + logglobal.SetLoggerProvider(handle.LoggerProvider) + } otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{})) }