Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions go/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type BootstrapEnv struct {
Endpoint string
TracesEndpoint string
MetricsEndpoint string
LogsEndpoint string
Token string
AuthURL string
ClientID string
Expand Down Expand Up @@ -116,13 +117,18 @@ 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,
Environment: env.Environment,
Release: env.Release,
TracesEndpoint: traceEndpoint,
MetricsEndpoint: metricEndpoint,
LogsEndpoint: logEndpoint,
Headers: headers,
TokenProvider: tokenProvider,
})
Expand Down Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions go/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
60 changes: 60 additions & 0 deletions go/logs.go
Original file line number Diff line number Diff line change
@@ -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()))
}
101 changes: 101 additions & 0 deletions go/logs_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
23 changes: 23 additions & 0 deletions go/otel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -168,13 +183,21 @@ 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)
}
if handle.MeterProvider != nil {
otel.SetMeterProvider(handle.MeterProvider)
}
if handle.LoggerProvider != nil {
logglobal.SetLoggerProvider(handle.LoggerProvider)
}
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{}))
}
Expand Down
Loading