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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,8 @@ examples/internal/email-evals/email-evals
# Added by goreleaser init:
dist/

# btx spec cache
btx/.spec-cache/

# emacs
*~
2 changes: 2 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ linters:
- text: "package-comments:"
linters: [revive]
path: examples/
- linters: [forcetypeassert]
path: _test\.go



Expand Down
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ local-braintrust-replaces:
build:
go build ./...
for dir in $(NESTED_MODULE_DIRS); do go build -C $$dir ./...; done
go build -C btx ./...

clean:
go clean
Expand All @@ -41,24 +42,29 @@ clean:
test:
VCR_MODE=replay go test ./...
for dir in $(NESTED_MODULE_DIRS); do VCR_MODE=replay go test -C $$dir ./...; done
VCR_MODE=replay go test -C btx ./...

test-quiet:
VCR_MODE=replay go test ./... | grep -v -E "^ok|no test files|^\\?" || true
for dir in $(NESTED_MODULE_DIRS); do VCR_MODE=replay go test -C $$dir ./... | grep -v -E "^ok|no test files|^\\?" || true; done
VCR_MODE=replay go test -C btx ./... | grep -v -E "^ok|no test files|^\\?" || true

test-vcr-off:
VCR_MODE=off go test ./...
for dir in $(NESTED_MODULE_DIRS); do VCR_MODE=off go test -C $$dir ./...; done
VCR_MODE=off go test -C btx ./...

test-vcr-record:
VCR_MODE=record go test ./...
for dir in $(NESTED_MODULE_DIRS); do VCR_MODE=record go test -C $$dir ./...; done
VCR_MODE=record go test -C btx ./...

# Verify that VCR cassettes work without API keys
# This ensures VCR-enabled tests can run in CI/CD without credentials
test-vcr-verify:
env -u BRAINTRUST_API_KEY VCR_MODE=replay go test ./...
for dir in $(NESTED_MODULE_DIRS); do env -u BRAINTRUST_API_KEY VCR_MODE=replay go test -C $$dir ./...; done
env -u BRAINTRUST_API_KEY VCR_MODE=replay go test -C btx ./...

cover:
go test $$(go list ./... | grep -v /examples/) -coverpkg=./... -coverprofile=coverage.out
Expand All @@ -70,9 +76,11 @@ lint:
./scripts/apply_local_braintrust_replaces.sh
golangci-lint fmt -d
golangci-lint run ./...
cd btx && golangci-lint fmt -d && golangci-lint run ./...

fmt:
golangci-lint fmt
cd btx && golangci-lint fmt

mod-verify:
./scripts/apply_local_braintrust_replaces.sh
Expand All @@ -81,10 +89,13 @@ mod-verify:
# This preserves explicit version pins in nested go.mod files (e.g. set by
# prepare_release.sh before tags exist) rather than resetting them to v0.0.0.
for dir in $(NESTED_MODULE_DIRS); do GOWORK=off go mod tidy -C $$dir; done
GOWORK=off go mod tidy -C btx
go mod verify
for dir in $(NESTED_MODULE_DIRS); do (cd $$dir && go mod verify); done
(cd btx && go mod verify)
git diff --exit-code go.mod go.sum \
$(foreach dir,$(NESTED_MODULE_DIRS),$(dir)/go.mod $(dir)/go.sum)
$(foreach dir,$(NESTED_MODULE_DIRS),$(dir)/go.mod $(dir)/go.sum) \
btx/go.mod btx/go.sum
./scripts/check_nested_modules.sh

check-nested-modules:
Expand Down
220 changes: 220 additions & 0 deletions btx/btx_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
package btx

import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
sdktrace "go.opentelemetry.io/otel/sdk/trace"

braintrust "github.com/braintrustdata/braintrust-sdk-go"
"github.com/braintrustdata/braintrust-sdk-go/internal/oteltest"
"github.com/braintrustdata/braintrust-sdk-go/internal/vcr"
"github.com/braintrustdata/braintrust-sdk-go/trace/attachmentprocessor"
)

// skipSpecs lists spec display names that should be skipped.
// Add specs here when they test features not yet supported by the Go SDK.
var skipSpecs = map[string]string{
// Prompt caching metrics (prompt_cache_creation_5m_tokens etc.) are not yet
// tracked by the Go SDK's Anthropic instrumentation.
"anthropic/prompt_caching_5m": "prompt caching metrics not yet supported",
"anthropic/prompt_caching_1h": "prompt caching metrics not yet supported",
// Bedrock attachment format: the Go SDK puts the braintrust_attachment
// reference under source (Anthropic-style), but the spec expects it under
// image.source.bytes (Bedrock-native nesting).
"bedrock/attachments": "attachment nesting format differs from spec",
}

// specRoot is set by TestMain after fetching specs.
var specRoot string

func TestMain(m *testing.M) {
root, err := fetchSpec()
if err != nil {
fmt.Fprintf(os.Stderr, "btx: failed to fetch spec: %v\n", err)
os.Exit(1)
}
specRoot = root

// Default BRAINTRUST_AUTO_CONVERT_AI_ATTACHMENTS based on VCR mode,
// but only if the user hasn't explicitly set it.
if os.Getenv("BRAINTRUST_AUTO_CONVERT_AI_ATTACHMENTS") == "" {
val := "false"
if vcr.GetVCRMode() == vcr.ModeReplay {
val = "true"
}
_ = os.Setenv("BRAINTRUST_AUTO_CONVERT_AI_ATTACHMENTS", val)
}

os.Exit(m.Run())
}

func TestBTXSpec(t *testing.T) {
providers := []string{"openai", "anthropic", "google", "bedrock"}
specs, err := loadSpecs(specRoot, providers)
require.NoError(t, err, "failed to load specs")
require.NotEmpty(t, specs, "no specs found")

for _, spec := range specs {
t.Run(spec.DisplayName, func(t *testing.T) {
if reason, ok := skipSpecs[spec.DisplayName]; ok {
t.Skipf("skipped: %s", reason)
}

runSpec(t, spec)
})
}
}

func runSpec(t *testing.T, spec LlmSpanSpec) {
t.Helper()

mode := vcr.GetVCRMode()
httpClient := newBTXHTTPClient(t, spec)
ctx := t.Context()

var spans []map[string]any

if mode == vcr.ModeReplay {
// Replay mode: capture spans in-memory, no network calls.
tp, exporter := oteltest.Setup(t)

traceID, err := executeSpec(ctx, spec, tp, httpClient)
require.NoError(t, err, "spec execution failed")
require.NotEmpty(t, traceID, "empty trace ID")

spans = convertExportedSpans(exporter)
} else {
// Record and off modes: hit real APIs and export spans to the real
// Braintrust backend, then fetch them back via BTQL for validation.
tp := sdktrace.NewTracerProvider()
projectName := btxProjectName()
_, err := braintrust.New(tp, braintrust.WithProject(projectName))
require.NoError(t, err, "failed to create Braintrust client")

traceID, err := executeSpec(ctx, spec, tp, httpClient)
require.NoError(t, err, "spec execution failed")
require.NotEmpty(t, traceID, "empty trace ID")

// Shut down to flush all spans to the backend.
require.NoError(t, tp.Shutdown(context.Background()), "failed to shutdown tracer provider")

projectID := btxProjectID(t)
spans, err = fetchSpansBTQL(traceID, projectID, len(spec.ExpectedBrainstoreSpans))
require.NoError(t, err, "failed to fetch spans from BTQL")
}

err := validateSpans(spans, spec)
if err != nil {
t.Fatal(err)
}
}

// convertExportedSpans converts in-memory OTel spans to brainstore format.
// It also runs the attachment processor to transform inline base64 data URLs
// into braintrust_attachment references, mirroring what the Braintrust span
// processor does in production.
func convertExportedSpans(exporter *oteltest.Exporter) []map[string]any {
otelSpans := exporter.Flush()

// Create an attachment processor with a no-op uploader so that base64
// data URLs are replaced with braintrust_attachment references without
// actually uploading anything.
ap := attachmentprocessor.NewProcessor(&attachmentprocessor.NoopUploader{}, nil)

var result []map[string]any
for _, span := range otelSpans {
// Extract all string attributes into a map.
attrs := make(map[string]string)
hasBraintrustAttr := false
for _, a := range span.Stub.Attributes {
if a.Value.Type().String() == "STRING" {
key := string(a.Key)
attrs[key] = a.Value.AsString()
if !hasBraintrustAttr && len(key) > 11 && key[:11] == "braintrust." {
hasBraintrustAttr = true
}
}
}

// Only include spans that have braintrust attributes.
if !hasBraintrustAttr {
continue
}

// Process attachments in input and output JSON, converting inline
// base64 data to braintrust_attachment references.
for _, key := range []string{"braintrust.input_json", "braintrust.output_json"} {
if v, ok := attrs[key]; ok {
attrs[key] = ap.ProcessAndUpload(v)
}
}

brainstoreSpan := spanFromOTel(span.Name(), attrs)
result = append(result, brainstoreSpan)
}

return result
}

// newBTXHTTPClient creates an HTTP client with VCR support using custom cassette paths.
// Cassettes are stored at testdata/cassettes/<provider>/<spec_name>.yaml.
func newBTXHTTPClient(t *testing.T, spec LlmSpanSpec) *http.Client {
t.Helper()

mode := vcr.GetVCRMode()
if mode == vcr.ModeOff {
return &http.Client{Timeout: 120 * time.Second}
}

// Build cassette path: testdata/cassettes/<provider>/<name>
// (go-vcr appends .yaml automatically)
cassettePath := filepath.Join("testdata", "cassettes", spec.Provider, spec.Name)

r, err := vcr.NewVCRRecorder(t, cassettePath)
require.NoError(t, err, "failed to create VCR recorder for %s", spec.DisplayName)

t.Cleanup(func() {
if err := r.Stop(); err != nil {
t.Errorf("failed to stop VCR recorder: %v", err)
}
})

return &http.Client{
Transport: r,
Timeout: 30 * time.Second,
}
}

// btxProjectName returns the Braintrust project name for live/record mode.
func btxProjectName() string {
if name := os.Getenv("BRAINTRUST_PROJECT"); name != "" {
return name
}
if name := os.Getenv("BRAINTRUST_DEFAULT_PROJECT_NAME"); name != "" {
return name
}
return "go-unit-test"
}

// btxProjectID returns the Braintrust project ID for live mode BTQL queries.
// It checks ID env vars first, then falls back to resolving the project name
// to an ID via the API.
func btxProjectID(t *testing.T) string {
t.Helper()
if id := os.Getenv("BRAINTRUST_PROJECT_ID"); id != "" {
return id
}
if id := os.Getenv("BRAINTRUST_DEFAULT_PROJECT_ID"); id != "" {
return id
}
id, err := resolveProjectID(btxProjectName())
require.NoError(t, err, "failed to resolve project %q to ID", btxProjectName())
return id
}
84 changes: 84 additions & 0 deletions btx/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
module github.com/braintrustdata/braintrust-sdk-go/btx

go 1.25.0

toolchain go1.26.1

require (
github.com/anthropics/anthropic-sdk-go v1.23.0
github.com/aws/aws-sdk-go-v2/config v1.32.15
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/braintrustdata/braintrust-sdk-go v0.7.0
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/anthropic v0.6.1
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/bedrockruntime v0.6.1
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/genai v0.6.1
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/openai v0.6.1
github.com/google/uuid v1.6.0
github.com/openai/openai-go v1.12.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
google.golang.org/genai v1.41.0
gopkg.in/yaml.v3 v3.0.1
)

require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.25.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.81.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/dnaeon/go-vcr.v3 v3.2.0 // indirect
)

replace (
github.com/braintrustdata/braintrust-sdk-go => ..
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/anthropic => ../trace/contrib/anthropic
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/bedrockruntime => ../trace/contrib/bedrockruntime
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/genai => ../trace/contrib/genai
github.com/braintrustdata/braintrust-sdk-go/trace/contrib/openai => ../trace/contrib/openai
)
Loading