diff --git a/README.md b/README.md index 8c8c7bd..d9d554e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ import ( _ "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/openai" // OpenAI (openai-go) _ "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/anthropic" // Anthropic _ "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/genai" // Google GenAI + _ "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/langchaingo" // LangChainGo OpenAI _ "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/github.com/sashabaranov/go-openai" // sashabaranov/go-openai ) ``` diff --git a/examples/internal/autoinstrumentation/go.mod b/examples/internal/autoinstrumentation/go.mod index a087f4d..d1d4224 100644 --- a/examples/internal/autoinstrumentation/go.mod +++ b/examples/internal/autoinstrumentation/go.mod @@ -1,6 +1,6 @@ module github.com/braintrustdata/braintrust-sdk-go/examples/internal/autoinstrumentation -go 1.24.0 +go 1.24.4 toolchain go1.24.11 @@ -51,6 +51,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/dave/dst v0.27.3 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.9.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -71,7 +72,7 @@ require ( github.com/hashicorp/go-version v1.7.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.1 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -90,6 +91,7 @@ require ( github.com/outcaste-io/ristretto v0.2.3 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pkoukk/tiktoken-go v0.1.6 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/polyfloyd/go-errorlint v1.8.1-0.20250906200200-9b25878c4dea // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect @@ -108,6 +110,7 @@ require ( github.com/tinylib/msgp v1.4.0 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect + github.com/tmc/langchaingo v0.1.14 // indirect github.com/urfave/cli/v2 v2.27.7 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect diff --git a/examples/internal/autoinstrumentation/go.sum b/examples/internal/autoinstrumentation/go.sum index 0ddf84d..2057a6f 100644 --- a/examples/internal/autoinstrumentation/go.sum +++ b/examples/internal/autoinstrumentation/go.sum @@ -88,6 +88,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= @@ -142,6 +144,7 @@ github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3J github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -202,6 +205,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= +github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -269,6 +274,8 @@ github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8O github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= +github.com/tmc/langchaingo v0.1.14 h1:o1qWBPigAIuFvrG6cjTFo0cZPFEZ47ZqpOYMjM15yZc= +github.com/tmc/langchaingo v0.1.14/go.mod h1:aKKYXYoqhIDEv7WKdpnnCLRaqXic69cX9MnDUk72378= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= github.com/vmihailenco/msgpack/v4 v4.3.13 h1:A2wsiTbvp63ilDaWmsk2wjx6xZdxQOvpiNlKBGKKXKI= diff --git a/examples/internal/autoinstrumentation/main.go b/examples/internal/autoinstrumentation/main.go index 2bd4d0b..cd7c3ca 100644 --- a/examples/internal/autoinstrumentation/main.go +++ b/examples/internal/autoinstrumentation/main.go @@ -4,8 +4,9 @@ // - OpenAI (openai-go official SDK) // - Anthropic // - sashabaranov/go-openai +// - LangChainGo (OpenAI provider) // -// Note: NO manual middleware is added to any client. +// Note: NO manual middleware or callbacks are added to any client. // When built with `orchestrion go build`, tracing middleware is injected at compile time. // // To run: @@ -25,6 +26,8 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/openai/openai-go" sashabaranov "github.com/sashabaranov/go-openai" + "github.com/tmc/langchaingo/llms" + langchainopenai "github.com/tmc/langchaingo/llms/openai" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/sdk/trace" @@ -71,6 +74,10 @@ func main() { fmt.Println("3. sashabaranov/go-openai...") runSashabaranov(ctx) + // 4. LangChainGo - NO callback added + fmt.Println("4. LangChainGo (OpenAI)...") + runLangChainGo(ctx) + fmt.Println("\n=== All providers tested ===") fmt.Println("If tracing worked, you should see LLM spans for each provider in Braintrust.") fmt.Printf("View trace: %s\n", bt.Permalink(rootSpan)) @@ -128,3 +135,21 @@ func runSashabaranov(ctx context.Context) { } fmt.Printf(" Response: %s\n", resp.Choices[0].Message.Content) } + +func runLangChainGo(ctx context.Context) { + // NO callback - Orchestrion injects it + llm, err := langchainopenai.New() + if err != nil { + log.Printf(" LangChainGo error: %v", err) + return + } + + resp, err := llm.GenerateContent(ctx, []llms.MessageContent{ + llms.TextParts(llms.ChatMessageTypeHuman, "Say 'Hello from LangChainGo' in exactly those words."), + }) + if err != nil { + log.Printf(" LangChainGo error: %v", err) + return + } + fmt.Printf(" Response: %s\n", resp.Choices[0].Content) +} diff --git a/internal/genorchestrion/genorchestrion_test.go b/internal/genorchestrion/genorchestrion_test.go index f4b19e5..61bacfc 100644 --- a/internal/genorchestrion/genorchestrion_test.go +++ b/internal/genorchestrion/genorchestrion_test.go @@ -41,6 +41,7 @@ func TestGenerate(t *testing.T) { "anthropic-newclient-middleware", "sashabaranov-newclientwithconfig-wrap", "genai-newclient-wrap", + "langchaingo-openai-newllm-callback", } for _, expected := range expectedAspects { @@ -74,10 +75,10 @@ func TestGenerateExcludesAllDirectory(t *testing.T) { t.Fatalf("Failed to parse generated YAML: %v", err) } - // Count aspects - should be exactly 5 (one per provider, plus openai-v2) + // Count aspects - should be exactly 6 (one per provider, plus openai-v2) // If it's more, we might be including duplicates from all/ - if len(result.Aspects) != 5 { - t.Errorf("expected 5 aspects, got %d", len(result.Aspects)) + if len(result.Aspects) != 6 { + t.Errorf("expected 6 aspects, got %d", len(result.Aspects)) } } diff --git a/trace/contrib/README.md b/trace/contrib/README.md index 87b8743..3ede2d7 100644 --- a/trace/contrib/README.md +++ b/trace/contrib/README.md @@ -8,6 +8,9 @@ Set up OpenTelemetry and initialize Braintrust: ```go import ( + "context" + "log" + "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/sdk/trace" "github.com/braintrustdata/braintrust-sdk-go" @@ -30,86 +33,129 @@ func main() { ```go import ( + "context" + "github.com/openai/openai-go" "github.com/openai/openai-go/option" traceopenai "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/openai" ) -client := openai.NewClient( - option.WithMiddleware(traceopenai.NewMiddleware()), -) - -_, err := client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ - Messages: []openai.ChatCompletionMessageParamUnion{ - openai.UserMessage("Hello!"), - }, - Model: openai.ChatModelGPT4oMini, -}) +func main() { + ctx := context.Background() + client := openai.NewClient( + option.WithMiddleware(traceopenai.NewMiddleware()), + ) + + _, _ = client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("Hello!"), + }, + Model: openai.ChatModelGPT4oMini, + }) +} ``` ## Anthropic ```go import ( + "context" + "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" traceanthropic "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/anthropic" ) -client := anthropic.NewClient( - option.WithMiddleware(traceanthropic.NewMiddleware()), -) - -_, err := client.Messages.New(ctx, anthropic.MessageNewParams{ - Model: anthropic.ModelClaude3_7SonnetLatest, - Messages: []anthropic.MessageParam{ - anthropic.NewUserMessage(anthropic.NewTextBlock("Hello!")), - }, - MaxTokens: 1024, -}) +func main() { + ctx := context.Background() + client := anthropic.NewClient( + option.WithMiddleware(traceanthropic.NewMiddleware()), + ) + + _, _ = client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.ModelClaude3_7SonnetLatest, + Messages: []anthropic.MessageParam{ + anthropic.NewUserMessage(anthropic.NewTextBlock("Hello!")), + }, + MaxTokens: 1024, + }) +} ``` ## Google Gemini ```go import ( + "context" + "os" + "google.golang.org/genai" tracegenai "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/genai" ) -client, err := genai.NewClient(ctx, &genai.ClientConfig{ - HTTPClient: tracegenai.Client(), - APIKey: os.Getenv("GOOGLE_API_KEY"), - Backend: genai.BackendGeminiAPI, -}) - -_, err = client.Models.GenerateContent(ctx, - "gemini-1.5-flash", - genai.Text("Hello!"), - nil, -) +func main() { + ctx := context.Background() + client, _ := genai.NewClient(ctx, &genai.ClientConfig{ + HTTPClient: tracegenai.Client(), + APIKey: os.Getenv("GOOGLE_API_KEY"), + Backend: genai.BackendGeminiAPI, + }) + + _, _ = client.Models.GenerateContent(ctx, + "gemini-1.5-flash", + genai.Text("Hello!"), + nil, + ) +} ``` ## sashabaranov/go-openai ```go import ( + "context" + "os" + "github.com/sashabaranov/go-openai" traceopenai "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/github.com/sashabaranov/go-openai" ) -config := openai.DefaultConfig(os.Getenv("OPENAI_API_KEY")) -config.HTTPClient = traceopenai.Client() -client := openai.NewClientWithConfig(config) - -_, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ - Model: openai.GPT4oMini, - Messages: []openai.ChatCompletionMessage{ - {Role: openai.ChatMessageRoleUser, Content: "Hello!"}, - }, -}) +func main() { + ctx := context.Background() + config := openai.DefaultConfig(os.Getenv("OPENAI_API_KEY")) + config.HTTPClient = traceopenai.Client() + client := openai.NewClientWithConfig(config) + + _, _ = client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: openai.GPT4oMini, + Messages: []openai.ChatCompletionMessage{ + {Role: openai.ChatMessageRoleUser, Content: "Hello!"}, + }, + }) +} ``` ## LangChainGo -See [`examples/langchaingo`](../../examples/langchaingo/main.go) for LangChainGo integration with callback-based tracing. +```go +import ( + "context" + + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/llms/openai" + tracelangchaingo "github.com/braintrustdata/braintrust-sdk-go/trace/contrib/langchaingo" +) + +func main() { + ctx := context.Background() + handler := tracelangchaingo.NewHandler() + llm, _ := openai.New(openai.WithCallback(handler)) + + _, _ = llm.GenerateContent(ctx, []llms.MessageContent{ + llms.TextParts(llms.ChatMessageTypeHuman, "Hello!"), + }) +} +``` + +For richer traces, use `NewHandlerWithOptions` with `TracerProvider`, `Model`, and `Provider` options. +See [`examples/langchaingo`](../../examples/langchaingo/main.go) for complete examples. diff --git a/trace/contrib/all/orchestrion.yml b/trace/contrib/all/orchestrion.yml index d138af1..9c41db2 100644 --- a/trace/contrib/all/orchestrion.yml +++ b/trace/contrib/all/orchestrion.yml @@ -53,6 +53,17 @@ aspects: __config.HTTPClient = traceopenai.WrapHTTPDoer(__config.HTTPClient) return openai.NewClientWithConfig(__config) }() + - id: langchaingo-openai-newllm-callback + join-point: + function-call: github.com/tmc/langchaingo/llms/openai.New + advice: + - append-args: + type: github.com/tmc/langchaingo/llms/openai.Option + values: + - imports: + tracelangchaingo: github.com/braintrustdata/braintrust-sdk-go/trace/contrib/langchaingo + openai: github.com/tmc/langchaingo/llms/openai + template: openai.WithCallback(tracelangchaingo.NewOpenAIHandler()) - id: openai-newclient-middleware join-point: function-call: github.com/openai/openai-go.NewClient diff --git a/trace/contrib/langchaingo/orchestrion.yml b/trace/contrib/langchaingo/orchestrion.yml new file mode 100644 index 0000000..6e6d15a --- /dev/null +++ b/trace/contrib/langchaingo/orchestrion.yml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://datadoghq.dev/orchestrion/schema.json +meta: + name: github.com/braintrustdata/braintrust-sdk-go/trace/contrib/langchaingo + description: Auto-instrumentation for LangChainGo OpenAI provider with Braintrust tracing + +aspects: + - id: langchaingo-openai-newllm-callback + join-point: + function-call: github.com/tmc/langchaingo/llms/openai.New + advice: + - append-args: + type: github.com/tmc/langchaingo/llms/openai.Option + values: + - imports: + tracelangchaingo: github.com/braintrustdata/braintrust-sdk-go/trace/contrib/langchaingo + openai: github.com/tmc/langchaingo/llms/openai + template: openai.WithCallback(tracelangchaingo.NewOpenAIHandler()) diff --git a/trace/contrib/langchaingo/tracelangchaingo.go b/trace/contrib/langchaingo/tracelangchaingo.go index 304a629..74b0976 100644 --- a/trace/contrib/langchaingo/tracelangchaingo.go +++ b/trace/contrib/langchaingo/tracelangchaingo.go @@ -109,6 +109,18 @@ func NewHandler() *Handler { } } +// NewOpenAIHandler creates a new Handler pre-configured for the LangChainGo OpenAI provider. +// This is used by auto-instrumentation to provide correct provider metadata. +func NewOpenAIHandler() *Handler { + return &Handler{ + spans: make(map[context.Context][]spanEntry), + streamBuffers: make(map[string]*strings.Builder), + opts: HandlerOptions{ + Provider: "openai", + }, + } +} + // NewHandlerWithOptions creates a new Handler with custom options. func NewHandlerWithOptions(opts HandlerOptions) *Handler { return &Handler{ diff --git a/trace/contrib/readme_test.go b/trace/contrib/readme_test.go new file mode 100644 index 0000000..4f05eca --- /dev/null +++ b/trace/contrib/readme_test.go @@ -0,0 +1,102 @@ +package contrib + +import ( + _ "embed" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +//go:embed README.md +var readme string + +func TestReadmeSnippets(t *testing.T) { + lines := strings.Split(readme, "\n") + var snippet []string + snippetCount := 0 + + // Collect all snippets first + type codeSnippet struct { + num int + code string + } + var snippets []codeSnippet + + for _, line := range lines { + if strings.HasPrefix(line, "```go") { + snippet = []string{} + continue + } + if strings.HasPrefix(line, "```") && snippet != nil { + snippetCount++ + code := strings.Join(snippet, "\n") + snippets = append(snippets, codeSnippet{num: snippetCount, code: code}) + snippet = nil + continue + } + if snippet != nil { + snippet = append(snippet, line) + } + } + + if len(snippets) == 0 { + t.Error("No Go code snippets found in README.md") + return + } + + // Compile all snippets in parallel using subtests + for _, s := range snippets { + t.Run(strconv.Itoa(s.num), func(t *testing.T) { + t.Parallel() + tmpDir := t.TempDir() + if err := tryCompile(t, tmpDir, s.num, s.code); err != nil { + t.Errorf("README snippet %d failed to compile: %v\n%s", s.num, err, s.code) + } + }) + } +} + +func tryCompile(t *testing.T, tmpDir string, snippetNum int, code string) error { + t.Helper() + + trimmed := strings.TrimSpace(code) + + // Skip build-constrained files (e.g., //go:build tools) - they can't be compiled standalone + if strings.HasPrefix(trimmed, "//go:build") { + t.Skip("skipping build-constrained snippet") + return nil + } + + // Create snippet file in temp directory + snippetPath := filepath.Join(tmpDir, "snippet"+strconv.Itoa(snippetNum)+".go") + + // Add "package main" if not already there + if !strings.HasPrefix(trimmed, "package main") { + code = "package main\n\n" + code + } + + if err := os.WriteFile(snippetPath, []byte(code), 0644); err != nil { + return err + } + + // Build in temp directory to avoid conflicts + outputBinary := filepath.Join(tmpDir, "snippet") + cmd := exec.Command("go", "build", "-o", outputBinary, snippetPath) + output, err := cmd.CombinedOutput() + if err != nil { + return &compileError{err: err, output: string(output)} + } + return nil +} + +type compileError struct { + err error + output string +} + +func (e *compileError) Error() string { + return e.err.Error() + "\nCompilation output:\n" + e.output +}