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
2 changes: 2 additions & 0 deletions cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ func runGateway() {
defer traceCollector.Stop()
// OTel OTLP export: compiled via build tags. Build with 'go build -tags otel' to enable.
initOTelExporter(context.Background(), cfg, traceCollector)
// LangSmith export: compiled via build tags. Build with 'go build -tags langsmith' to enable.
initLangSmithExporter(context.Background(), cfg, traceCollector)
}
if snapshotWorker != nil {
defer snapshotWorker.Stop()
Expand Down
42 changes: 42 additions & 0 deletions cmd/gateway_langsmith.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//go:build langsmith

package cmd

import (
"context"
"log/slog"

"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
"github.com/nextlevelbuilder/goclaw/internal/tracing/langsmithexport"
)

// initLangSmithExporter creates and wires the LangSmith exporter when
// the API key is configured. Only compiled with -tags langsmith.
func initLangSmithExporter(_ context.Context, cfg *config.Config, collector *tracing.Collector) {
if collector == nil {
return
}
if cfg.LangSmith.APIKey == "" {
slog.Debug("LangSmith export available but not enabled (set LANGSMITH_API_KEY)")
return
}

exp, err := langsmithexport.New(langsmithexport.Config{
APIKey: cfg.LangSmith.APIKey,
Project: cfg.LangSmith.Project,
APIUrl: cfg.LangSmith.APIUrl,
})
if err != nil {
slog.Warn("failed to create LangSmith exporter", "error", err)
return
}

collector.AddExporter(exp)

project := cfg.LangSmith.Project
if project == "" {
project = "default"
}
slog.Info("LangSmith export enabled", "project", project)
}
15 changes: 15 additions & 0 deletions cmd/gateway_langsmith_noop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !langsmith

package cmd

import (
"context"

"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)

// initLangSmithExporter is a no-op when built without the "langsmith" tag.
// Build with `go build -tags langsmith` to enable LangSmith export.
func initLangSmithExporter(_ context.Context, _ *config.Config, _ *tracing.Collector) {
}
17 changes: 11 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ require (
github.com/titanous/json5 v1.0.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/zalando/go-keyring v0.2.8
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
golang.org/x/time v0.14.0
modernc.org/sqlite v1.47.0
tailscale.com v1.94.2
Expand Down Expand Up @@ -84,6 +84,7 @@ require (
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/langchain-ai/langsmith-go v0.2.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
Expand Down Expand Up @@ -114,6 +115,10 @@ require (
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
Expand Down Expand Up @@ -154,7 +159,7 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lib/pq v1.10.9
github.com/mark3labs/mcp-go v0.44.0
Expand All @@ -172,7 +177,7 @@ require (
github.com/ysmood/leakless v0.9.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/crypto v0.47.0 // indirect
Expand All @@ -183,6 +188,6 @@ require (
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
25 changes: 25 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUF
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
Expand All @@ -267,6 +269,8 @@ github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaa
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/langchain-ai/langsmith-go v0.2.2 h1:WVNUR9dhnuieIaXrKxmZWva6TX1DV/RHCVgc67wbAbs=
github.com/langchain-ai/langsmith-go v0.2.2/go.mod h1:xdfOA0EBT7KF9ylz+gGq8EM6srRkv2PtpazQ+4oraWk=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
Expand Down Expand Up @@ -415,6 +419,16 @@ github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
Expand Down Expand Up @@ -475,6 +489,8 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGN
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
Expand All @@ -483,12 +499,19 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXI
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
Expand Down Expand Up @@ -559,6 +582,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ type Config struct {
Tts TtsConfig `json:"tts"`
Cron CronConfig `json:"cron"`
Telemetry TelemetryConfig `json:"telemetry"`
LangSmith LangSmithConfig `json:"langsmith"`
Tailscale TailscaleConfig `json:"tailscale"`
Bindings []AgentBinding `json:"bindings,omitempty"`
mu sync.RWMutex
Expand Down Expand Up @@ -316,6 +317,15 @@ type TelemetryConfig struct {
ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model"
}

// LangSmithConfig configures the LangSmith tracing exporter.
// When APIKey is set (or LANGSMITH_API_KEY env var), spans are exported to
// LangSmith as runs for AI-specific observability.
type LangSmithConfig struct {
APIKey string `json:"api_key,omitempty"` // LangSmith API key (required to enable)
Project string `json:"project,omitempty"` // project name (default: "default")
APIUrl string `json:"api_url,omitempty"` // API URL override (default: LangSmith cloud)
}

// CronConfig configures the cron job system.
type CronConfig struct {
MaxRetries int `json:"max_retries,omitempty"` // max retry attempts on failure (default 3, 0 = no retry)
Expand Down Expand Up @@ -389,6 +399,7 @@ func (c *Config) ReplaceFrom(src *Config) {
c.Tts = src.Tts
c.Cron = src.Cron
c.Telemetry = src.Telemetry
c.LangSmith = src.LangSmith
c.Tailscale = src.Tailscale
c.Bindings = src.Bindings
}
Expand Down
5 changes: 5 additions & 0 deletions internal/config/config_load.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ func (c *Config) applyEnvOverrides() {
c.Telemetry.Insecure = v == "true" || v == "1"
}

// LangSmith (uses standard LangSmith env var names)
envStr("LANGSMITH_API_KEY", &c.LangSmith.APIKey)
envStr("LANGSMITH_PROJECT", &c.LangSmith.Project)
envStr("LANGSMITH_ENDPOINT", &c.LangSmith.APIUrl)

// Owner IDs from env (comma-separated, whitespace-trimmed)
if v := os.Getenv("GOCLAW_OWNER_IDS"); v != "" {
var ids []string
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ func (c *Config) MaskedCopy() *Config {
// Mask Tailscale auth key
maskNonEmpty(&cp.Tailscale.AuthKey)

// Mask LangSmith API key
maskNonEmpty(&cp.LangSmith.APIKey)

return cp
}

Expand Down Expand Up @@ -113,6 +116,9 @@ func (c *Config) StripSecrets() {

// Tailscale auth key
c.Tailscale.AuthKey = ""

// LangSmith API key
c.LangSmith.APIKey = ""
}

// StripMaskedSecrets strips only fields that still contain the mask value "***".
Expand Down Expand Up @@ -168,6 +174,9 @@ func (c *Config) StripMaskedSecrets() {

// Tailscale auth key
stripIfMasked(&c.Tailscale.AuthKey)

// LangSmith API key
stripIfMasked(&c.LangSmith.APIKey)
}

// ApplyDBSecrets overlays secrets from the config_secrets table onto the config.
Expand Down
68 changes: 51 additions & 17 deletions internal/tracing/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,31 @@ const (
)

// SpanExporter is implemented by backends that receive span data alongside
// the PostgreSQL store (e.g. OpenTelemetry OTLP). Keeping this as an
// interface lets the OTel dependency live in a separate sub-package that can
// be swapped out by commenting one import line.
// the PostgreSQL store (e.g. OpenTelemetry OTLP, LangSmith). Keeping this
// as an interface lets each dependency live in a separate sub-package gated
// by build tags.
type SpanExporter interface {
ExportSpans(ctx context.Context, spans []store.SpanData)
Shutdown(ctx context.Context) error
}

// spanUpdate represents a deferred span field update, buffered alongside new
// spans and applied during the same flush cycle (after batch INSERT).
// SpanUpdateExporter is an optional extension of SpanExporter for exporters
// that need to receive two-phase span updates (e.g. LangSmith RunUpdate).
// Exporters that implement this interface will receive deferred span updates
// during the flush cycle, after the initial spans have been exported.
type SpanUpdateExporter interface {
ExportSpanUpdates(ctx context.Context, updates []SpanUpdate)
}

// SpanUpdate is the exported form of a deferred span field update, passed to
// SpanUpdateExporter implementations during the flush cycle.
type SpanUpdate struct {
SpanID uuid.UUID
TraceID uuid.UUID
Updates map[string]any
}

// spanUpdate is the internal buffered form used by the collector channel.
type spanUpdate struct {
SpanID uuid.UUID
TraceID uuid.UUID
Expand All @@ -55,8 +70,8 @@ type Collector struct {
dirtyTraces map[uuid.UUID]struct{}
dirtyTracesMu sync.Mutex

verbose bool // when true, LLM spans include full input messages
exporter SpanExporter // optional external exporter (nil = disabled)
verbose bool // when true, LLM spans include full input messages
exporters []SpanExporter // optional external exporters (OTel, LangSmith, etc.)

// OnFlush is called after each flush cycle with the trace IDs that had
// their aggregates updated. Used to broadcast realtime trace events.
Expand Down Expand Up @@ -91,10 +106,16 @@ func (c *Collector) PreviewMaxLen() int {
return previewMaxLen
}

// SetExporter attaches an external span exporter (e.g. OpenTelemetry OTLP).
// When set, spans are exported to the external backend during each flush cycle.
// SetExporter replaces all exporters with a single one (backward compat).
// Prefer AddExporter for multi-exporter setups.
func (c *Collector) SetExporter(exp SpanExporter) {
c.exporter = exp
c.exporters = []SpanExporter{exp}
}

// AddExporter appends an exporter to the list. Multiple exporters receive
// the same batch of spans during each flush cycle (fan-out).
func (c *Collector) AddExporter(exp SpanExporter) {
c.exporters = append(c.exporters, exp)
}

// Start begins the background flush loop.
Expand All @@ -109,12 +130,14 @@ func (c *Collector) Stop() {
close(c.stopCh)
c.wg.Wait()

// Shutdown external exporter (flushes remaining spans)
if c.exporter != nil {
// Shutdown all external exporters (flushes remaining spans).
if len(c.exporters) > 0 {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := c.exporter.Shutdown(ctx); err != nil {
slog.Warn("tracing: span exporter shutdown failed", "error", err)
for _, exp := range c.exporters {
if err := exp.Shutdown(ctx); err != nil {
slog.Warn("tracing: span exporter shutdown failed", "error", err)
}
}
}

Expand Down Expand Up @@ -263,9 +286,9 @@ done:
slog.Debug("tracing: flushed spans", "count", len(spans))
}

// Export to external backend (non-blocking — errors logged, not propagated)
if c.exporter != nil {
c.exporter.ExportSpans(ctx, spans)
// Export to external backends (non-blocking — errors logged, not propagated).
for _, exp := range c.exporters {
exp.ExportSpans(ctx, spans)
}
}

Expand All @@ -290,6 +313,17 @@ doneUpdates:
}
}
slog.Debug("tracing: applied span updates", "count", len(updates))

// Fan out span updates to exporters that support two-phase tracing.
exported := make([]SpanUpdate, len(updates))
for i, u := range updates {
exported[i] = SpanUpdate{SpanID: u.SpanID, TraceID: u.TraceID, Updates: u.Updates}
}
for _, exp := range c.exporters {
if sue, ok := exp.(SpanUpdateExporter); ok {
sue.ExportSpanUpdates(ctx, exported)
}
}
}

// Update aggregates for dirty traces
Expand Down
Loading
Loading