diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go new file mode 100644 index 0000000..061bf97 --- /dev/null +++ b/cmd/otelgotest/main.go @@ -0,0 +1,279 @@ +// otelgotest is a drop-in go test replacement that emits OTel spans for +// each test. Usage: +// +// otelgotest ./... +// otelgotest -v -run TestFoo ./mypackage +// +// All flags and arguments are forwarded to go test. The command runs +// go test -json internally, parses the JSON event stream, and emits +// OTel spans via gotest.Run. Human-readable output is reconstructed +// on stdout. +// +// Unlike the old -exec approach (go test -exec otelgotest), this +// preserves test caching. +// +// Cross-process span context propagation is supported via a Unix +// socket (OTEL_TEST_SOCKET). Test binaries using oteltestctx can +// connect to this socket to adopt the externally created span context, +// making in-process operations children of the test span. +// +// If no OTLP endpoint is configured, go test is executed directly +// with no overhead. +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + otel "github.com/dagger/otel-go" + "github.com/dagger/otel-go/gotest" + otelgo "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" +) + +func main() { + os.Exit(run()) +} + +func run() int { + args := os.Args[1:] + + // Detect old -exec usage: go test -exec otelgotest passes + // a compiled .test binary as the first argument. + if len(args) > 0 && looksLikeExecMode(args[0]) { + fmt.Fprintf(os.Stderr, "otelgotest: it looks like you're using the old -exec mode.\n") + fmt.Fprintf(os.Stderr, " otelgotest is now a go test wrapper. Use:\n") + fmt.Fprintf(os.Stderr, " otelgotest ./...\n") + fmt.Fprintf(os.Stderr, " instead of:\n") + fmt.Fprintf(os.Stderr, " go test -exec otelgotest ./...\n") + return 1 + } + + // If no OTLP endpoint, just run go test directly. + if !hasOTLPEndpoint() { + return execGoTest(args) + } + + ctx := context.Background() + ctx = otel.InitEmbedded(ctx, nil) + defer otel.Close() + + tp := otelgo.GetTracerProvider() + + // Set up a Unix socket for cross-process span context propagation. + // Test binaries using oteltestctx can connect to this socket to + // retrieve the span context of the externally created test span. + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + tmpDir, err := os.MkdirTemp("", "otelgotest") + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: tmpdir: %v\n", err) + return execGoTest(args) + } + defer os.RemoveAll(tmpDir) + + socketPath := filepath.Join(tmpDir, "span.sock") + listener, err := net.Listen("unix", socketPath) + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: socket listen: %v\n", err) + return execGoTest(args) + } + defer listener.Close() + + go serveSpanContexts(listener, registry) + + // Build the go test -json command, forwarding all user args. + // If the user supplied a leading -C flag, it must stay before -json + // because the go command requires -C to be the first flag. + goTestArgs := buildGoTestArgs(args) + + cmd := exec.Command("go", goTestArgs...) + cmd.Env = append(os.Environ(), "OTEL_TEST_SOCKET="+socketPath) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + jsonOut, err := cmd.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: stdout pipe: %v\n", err) + return execGoTest(args) + } + + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: start: %v\n", err) + return execGoTest(args) + } + + // Process JSON events while preserving the user's requested output mode. + stream := io.Reader(jsonOut) + opts := []gotest.Option{ + gotest.WithSpanContextRegistry(registry), + } + if hasJSONFlag(args) { + stream = io.TeeReader(jsonOut, os.Stdout) + } else { + opts = append(opts, + gotest.WithOutput(os.Stdout), + gotest.WithVerbose(hasVerboseFlag(args)), + ) + } + if lp := otel.LoggerProvider(ctx); lp != nil { + opts = append(opts, gotest.WithLoggerProvider(lp)) + } + gotest.Run(ctx, stream, tp, opts...) + + cmd.Wait() + + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() + } + return 1 +} + +// execGoTest runs go test with no instrumentation. +func execGoTest(args []string) int { + goArgs := []string{"test"} + goArgs = append(goArgs, args...) + cmd := exec.Command("go", goArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Run() + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() + } + return 1 +} + +// buildGoTestArgs constructs the instrumented go test command line. +// +// If the user supplied a leading -C flag, it must stay before -json +// because the go command requires -C to be the first flag. +func buildGoTestArgs(args []string) []string { + chdirArgs, rest := splitLeadingChdirFlags(args) + + out := []string{"test"} + out = append(out, chdirArgs...) + out = append(out, "-json") + out = append(out, stripJSONFlag(rest)...) + return out +} + +// splitLeadingChdirFlags extracts any leading -C flags from args. +// It supports both "-C dir" and "-C=dir" forms. +func splitLeadingChdirFlags(args []string) (chdirArgs, rest []string) { + rest = args + for len(rest) > 0 { + switch { + case rest[0] == "-C": + if len(rest) < 2 { + return chdirArgs, rest + } + chdirArgs = append(chdirArgs, rest[0], rest[1]) + rest = rest[2:] + case strings.HasPrefix(rest[0], "-C="): + chdirArgs = append(chdirArgs, rest[0]) + rest = rest[1:] + default: + return chdirArgs, rest + } + } + return chdirArgs, rest +} + +// stripJSONFlag removes any user-supplied -json form since we add it ourselves. +func stripJSONFlag(args []string) []string { + var out []string + for _, arg := range args { + if arg != "-json" && arg != "-json=true" && arg != "-json=false" { + out = append(out, arg) + } + } + return out +} + +// looksLikeExecMode returns true if arg looks like a compiled test binary, +// indicating the user is trying the old go test -exec usage. +func looksLikeExecMode(arg string) bool { + return strings.HasSuffix(arg, ".test") || strings.Contains(arg, ".test ") +} + +// serveSpanContexts accepts connections on the Unix socket and responds +// with the traceparent of the requested test's span. Each connection is a +// single request/response: the client sends a package-qualified test name +// (e.g., "example.com/pkg/TestFoo") on one line, and the server responds +// with the W3C traceparent on one line. +func serveSpanContexts(listener net.Listener, registry *gotest.SpanContextRegistry) { + for { + conn, err := listener.Accept() + if err != nil { + return // listener closed + } + go handleSpanContextConn(conn, registry) + } +} + +func handleSpanContextConn(conn net.Conn, registry *gotest.SpanContextRegistry) { + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + return + } + testKey := scanner.Text() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sc, ok := registry.WaitFor(ctx, testKey) + if !ok { + return + } + + fmt.Fprintf(conn, "%s\n", formatTraceparent(sc)) +} + +func formatTraceparent(sc trace.SpanContext) string { + return fmt.Sprintf("00-%s-%s-%s", sc.TraceID(), sc.SpanID(), sc.TraceFlags()) +} + +// hasVerboseFlag checks if -v or -test.v is present in the user's args. +func hasVerboseFlag(args []string) bool { + for _, arg := range args { + if arg == "-v" || arg == "-test.v" || + arg == "-v=true" || arg == "-test.v=true" { + return true + } + if arg == "--" { + break + } + } + return false +} + +// hasJSONFlag checks if -json is present in the user's args. +func hasJSONFlag(args []string) bool { + for _, arg := range args { + if arg == "-json" || arg == "-json=true" { + return true + } + if arg == "--" { + break + } + } + return false +} + +func hasOTLPEndpoint() bool { + return os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" || + os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" +} diff --git a/cmd/otelgotest/main_test.go b/cmd/otelgotest/main_test.go new file mode 100644 index 0000000..4f39fe9 --- /dev/null +++ b/cmd/otelgotest/main_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "reflect" + "testing" +) + +func TestBuildGoTestArgs(t *testing.T) { + tests := []struct { + name string + args []string + want []string + }{ + { + name: "no chdir", + args: []string{"-run", "TestFoo", "./..."}, + want: []string{"test", "-json", "-run", "TestFoo", "./..."}, + }, + { + name: "leading dash C separate arg", + args: []string{"-C", "./foo/bar", "./..."}, + want: []string{"test", "-C", "./foo/bar", "-json", "./..."}, + }, + { + name: "leading dash C equals form", + args: []string{"-C=./foo/bar", "./..."}, + want: []string{"test", "-C=./foo/bar", "-json", "./..."}, + }, + { + name: "leading dash C with explicit json", + args: []string{"-C", "./foo/bar", "-json", "./..."}, + want: []string{"test", "-C", "./foo/bar", "-json", "./..."}, + }, + { + name: "strips explicit json true", + args: []string{"-json=true", "./..."}, + want: []string{"test", "-json", "./..."}, + }, + { + name: "strips explicit json false", + args: []string{"-json=false", "./..."}, + want: []string{"test", "-json", "./..."}, + }, + { + name: "non-leading dash C is left in place", + args: []string{"-run", "TestFoo", "-C", "./foo/bar", "./..."}, + want: []string{"test", "-json", "-run", "TestFoo", "-C", "./foo/bar", "./..."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildGoTestArgs(tt.args) + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("buildGoTestArgs(%q) = %q, want %q", tt.args, got, tt.want) + } + }) + } +} + +func TestHasJSONFlag(t *testing.T) { + tests := []struct { + name string + args []string + want bool + }{ + {name: "bare flag", args: []string{"-json"}, want: true}, + {name: "explicit true", args: []string{"-json=true"}, want: true}, + {name: "explicit false", args: []string{"-json=false"}, want: false}, + {name: "stops at double dash", args: []string{"--", "-json"}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasJSONFlag(tt.args); got != tt.want { + t.Fatalf("hasJSONFlag(%q) = %v, want %v", tt.args, got, tt.want) + } + }) + } +} diff --git a/cmd/oteljunit/main.go b/cmd/oteljunit/main.go new file mode 100644 index 0000000..be2f45e --- /dev/null +++ b/cmd/oteljunit/main.go @@ -0,0 +1,63 @@ +// oteljunit reads JUnit XML and emits OTel spans for each test. Usage: +// +// oteljunit report.xml [more.xml ...] +// cat report.xml | oteljunit +// +// Each test suite becomes a parent span, with child spans for each test +// case. Pass/fail/skip status and test output are captured. +// +// Requires OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT +// to be set. +package main + +import ( + "context" + "fmt" + "os" + + otel "github.com/dagger/otel-go" + "github.com/dagger/otel-go/junit" + otelgo "go.opentelemetry.io/otel" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "oteljunit: %v\n", err) + os.Exit(1) + } +} + +func run() error { + ctx := context.Background() + ctx = otel.InitEmbedded(ctx, nil) + defer otel.Close() + + tp := otelgo.GetTracerProvider() + + var opts []junit.Option + if lp := otel.LoggerProvider(ctx); lp != nil { + opts = append(opts, junit.WithLoggerProvider(lp)) + } + + if len(os.Args) > 1 { + // Read from file arguments. + for _, filename := range os.Args[1:] { + f, err := os.Open(filename) + if err != nil { + return err + } + if err := junit.Run(ctx, f, tp, opts...); err != nil { + f.Close() + return fmt.Errorf("%s: %w", filename, err) + } + f.Close() + } + } else { + // Read from stdin. + if err := junit.Run(ctx, os.Stdin, tp, opts...); err != nil { + return err + } + } + + return nil +} diff --git a/go.mod b/go.mod index eac028f..493d32c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/dagger/otel-go go 1.25.0 require ( + github.com/dagger/testctx v0.1.2 + github.com/joshdk/go-junit v1.0.0 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.41.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.17.0 go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.17.0 @@ -24,10 +27,13 @@ require ( require ( 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.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/jstemmer/go-junit-report/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect golang.org/x/net v0.51.0 // indirect @@ -36,4 +42,7 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) + +tool github.com/jstemmer/go-junit-report/v2 diff --git a/go.sum b/go.sum index 4e89978..865f43a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,9 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dagger/testctx v0.1.2 h1:p9/92CttP6N3AhTWnAOrGfFGGNSqD5uDgY32+LISFoE= +github.com/dagger/testctx v0.1.2/go.mod h1:2T9w5oiFscVVzgQo7YW2q1WxVkLFumHWjwlGQqVT/Sk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -11,14 +14,27 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipYafciZUWHEmsc= +github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -75,5 +91,9 @@ 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= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/gotest/gotest.go b/gotest/gotest.go new file mode 100644 index 0000000..1f1dbb3 --- /dev/null +++ b/gotest/gotest.go @@ -0,0 +1,422 @@ +// Package gotest reads a go test -json stream and emits OTel spans +// for each test, with proper parent/child nesting for subtests. +package gotest + +//go:generate sh -c "go -C testdata/sample test -json -count=1 ./... > testdata/sample.jsonl 2>&1 || true" + +import ( + "bufio" + "context" + "encoding/json" + "io" + "strings" + "time" + + otel "github.com/dagger/otel-go" + "go.opentelemetry.io/otel/codes" + sdklog "go.opentelemetry.io/otel/sdk/log" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" +) + +const instrumentationLibrary = "dagger.io/otelgotest" + +// TestEvent is the JSON structure emitted by go test -json. +// See: go doc cmd/test2json +type TestEvent struct { + Time time.Time `json:"Time"` + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test"` + Elapsed float64 `json:"Elapsed"` + Output string `json:"Output"` +} + +// testSpan tracks an in-flight span for a single test. +type testSpan struct { + span trace.Span + ctx context.Context + testName string + output strings.Builder + streams *otel.SpanStreams + bufferedOut strings.Builder // buffered output for non-verbose mode +} + +// Option configures the behavior of Run. +type Option func(*runConfig) + +type runConfig struct { + output io.Writer + verbose bool + loggerProvider *sdklog.LoggerProvider + registry *SpanContextRegistry +} + +// WithOutput passes through the human-readable test output (the Output +// field of each JSON event) to w. This reconstructs what go test would +// normally print, regardless of whether the caller is consuming JSON. +// +// By default the output mimics non-verbose go test: per-test output is +// only shown for failing tests. Use [WithVerbose] to show all output. +func WithOutput(w io.Writer) Option { + return func(c *runConfig) { c.output = w } +} + +// WithVerbose controls whether the human-readable output written via +// [WithOutput] includes per-test detail for passing tests. When false +// (the default), output is only shown for failing tests, matching the +// behavior of go test without -v. +// +// Note: go test -json always forces -test.v=test2json internally, +// which makes testing.Verbose() return true inside test binaries. +// This option only affects the reconstructed human-readable output; +// it cannot change the behavior of testing.Verbose(). +func WithVerbose(v bool) Option { + return func(c *runConfig) { c.verbose = v } +} + +// WithLoggerProvider routes test output to each test's span as OTel log +// records via [otel.SpanStdio]. Without this, output is only captured +// as span events. +func WithLoggerProvider(lp *sdklog.LoggerProvider) Option { + return func(c *runConfig) { c.loggerProvider = lp } +} + +// WithSpanContextRegistry configures a [SpanContextRegistry] that receives +// the span context of every test span created by [Run]. This allows an +// external coordinator (e.g., a Unix socket server) to serve span contexts +// to the test binary for cross-process context propagation. +func WithSpanContextRegistry(r *SpanContextRegistry) Option { + return func(c *runConfig) { c.registry = r } +} + +// Run reads a go test -json stream and emits OTel spans in real time. +func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Option) error { + var cfg runConfig + for _, opt := range opts { + opt(&cfg) + } + + tracer := tp.Tracer(instrumentationLibrary) + + // key: package-qualified full test name (package + "/" + ev.Test). + spans := map[string]*testSpan{} + + // activeTests tracks currently running tests per package. A test is active + // after "run" or "cont", inactive after "pause", and removed after + // "pass"/"fail"/"skip". Parentage is derived from the longest active + // test name prefix instead of splitting ev.Test on '/'; this preserves leaf + // names that themselves contain '/'. + activeTests := map[string]map[string]struct{}{} + + // pkgSpans tracks a parent span per package. + pkgSpans := map[string]*testSpan{} + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + var ev TestEvent + if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + // Non-JSON line (e.g., build error). Pass through to output. + if cfg.output != nil { + io.WriteString(cfg.output, scanner.Text()+"\n") + } + continue + } + + // Pass through the human-readable output. + // In verbose mode, write immediately. In non-verbose mode, + // buffer per-test output and only flush it on failure. + if cfg.output != nil && ev.Output != "" { + if ev.Test != "" && !cfg.verbose { + // Buffer test-specific output. + key := ev.Package + "/" + ev.Test + if ts, ok := spans[key]; ok { + ts.bufferedOut.WriteString(ev.Output) + } + } else if cfg.verbose || strings.TrimSpace(ev.Output) != "PASS" { + io.WriteString(cfg.output, ev.Output) + } + } + + // Handle package-level events (no test name). + if ev.Test == "" { + switch ev.Action { + case "start": + pkgCtx, pkgSpan := tracer.Start(ctx, ev.Package, + trace.WithTimestamp(ev.Time), + trace.WithAttributes( + semconv.TestSuiteName(ev.Package), + ), + ) + pkgSpans[ev.Package] = &testSpan{ + span: pkgSpan, + ctx: pkgCtx, + } + case "pass": + if ps, ok := pkgSpans[ev.Package]; ok { + ps.span.SetStatus(codes.Ok, "") + ps.span.SetAttributes(semconv.TestCaseResultStatusPass) + ps.span.End(trace.WithTimestamp(ev.Time)) + delete(pkgSpans, ev.Package) + } + delete(activeTests, ev.Package) + case "fail": + if ps, ok := pkgSpans[ev.Package]; ok { + ps.span.SetStatus(codes.Error, "package had failures") + ps.span.SetAttributes(semconv.TestSuiteRunStatusFailure) + ps.span.End(trace.WithTimestamp(ev.Time)) + delete(pkgSpans, ev.Package) + } + delete(activeTests, ev.Package) + case "skip": + if ps, ok := pkgSpans[ev.Package]; ok { + ps.span.SetStatus(codes.Ok, "skipped") + ps.span.SetAttributes(semconv.TestSuiteRunStatusSkipped) + ps.span.End(trace.WithTimestamp(ev.Time)) + delete(pkgSpans, ev.Package) + } + delete(activeTests, ev.Package) + } + continue + } + + key := ev.Package + "/" + ev.Test + + switch ev.Action { + case "run": + // Default parent is the package span, or the top-level ctx. + parentCtx := ctx + var parentName string + if ps, ok := pkgSpans[ev.Package]; ok { + parentCtx = ps.ctx + } + if active := activeTests[ev.Package]; active != nil { + longest := -1 + for activeKey := range active { + ts, ok := spans[activeKey] + if !ok { + continue + } + prefix := ts.testName + "/" + if strings.HasPrefix(ev.Test, prefix) && len(ts.testName) > longest { + parentCtx = ts.ctx + parentName = ts.testName + longest = len(ts.testName) + } + } + } + + spanName := ev.Test + if parentName != "" { + spanName = strings.TrimPrefix(ev.Test, parentName+"/") + } + + spanCtx, span := tracer.Start(parentCtx, spanName, + trace.WithTimestamp(ev.Time), + trace.WithAttributes( + semconv.TestCaseName(ev.Test), + semconv.TestSuiteName(ev.Package), + ), + ) + + ts := &testSpan{ + span: span, + ctx: spanCtx, + testName: ev.Test, + } + if cfg.loggerProvider != nil { + spanCtx = otel.WithLoggerProvider(spanCtx, cfg.loggerProvider) + streams := otel.SpanStdio(spanCtx, instrumentationLibrary) + ts.streams = &streams + } + spans[key] = ts + if activeTests[ev.Package] == nil { + activeTests[ev.Package] = map[string]struct{}{} + } + activeTests[ev.Package][key] = struct{}{} + + if cfg.registry != nil { + cfg.registry.Register(key, span.SpanContext()) + } + + case "pause": + if active := activeTests[ev.Package]; active != nil { + delete(active, key) + } + + case "cont": + if _, ok := spans[key]; ok { + if activeTests[ev.Package] == nil { + activeTests[ev.Package] = map[string]struct{}{} + } + activeTests[ev.Package][key] = struct{}{} + } + + case "output": + if ts, ok := spans[key]; ok { + // Filter out the === RUN / --- PASS/FAIL/SKIP lines. + trimmed := strings.TrimSpace(ev.Output) + if trimmed == "" || + strings.HasPrefix(trimmed, "=== RUN") || + strings.HasPrefix(trimmed, "=== PAUSE") || + strings.HasPrefix(trimmed, "=== CONT") || + strings.HasPrefix(trimmed, "--- PASS") || + strings.HasPrefix(trimmed, "--- FAIL") || + strings.HasPrefix(trimmed, "--- SKIP") { + continue + } + + ts.output.WriteString(ev.Output) + + // Route to span logs if configured. + if ts.streams != nil { + io.WriteString(ts.streams.Stdout, ev.Output) + } + } + + case "pass": + if active := activeTests[ev.Package]; active != nil { + delete(active, key) + } + if ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + // Non-verbose: discard buffered output for passing tests. + ts.span.SetStatus(codes.Ok, "test passed") + ts.span.SetAttributes(semconv.TestCaseResultStatusPass) + ts.span.End(trace.WithTimestamp(ev.Time)) + delete(spans, key) + } + + case "fail": + if active := activeTests[ev.Package]; active != nil { + delete(active, key) + } + if ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + // Non-verbose: flush buffered output for failing tests. + if cfg.output != nil && !cfg.verbose && ts.bufferedOut.Len() > 0 { + io.WriteString(cfg.output, ts.bufferedOut.String()) + } + desc := extractErrorOutput(ts.output.String()) + if desc == "" { + desc = "test failed" + } + ts.span.SetStatus(codes.Error, desc) + ts.span.SetAttributes(semconv.TestSuiteRunStatusFailure) + ts.span.End(trace.WithTimestamp(ev.Time)) + delete(spans, key) + } + + case "skip": + if active := activeTests[ev.Package]; active != nil { + delete(active, key) + } + if ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + // Non-verbose: discard buffered output for skipped tests. + ts.span.SetStatus(codes.Ok, "test skipped") + ts.span.SetAttributes(semconv.TestSuiteRunStatusSkipped) + ts.span.End(trace.WithTimestamp(ev.Time)) + delete(spans, key) + } + } + } + + return scanner.Err() +} + +// extractErrorOutput cleans up test output to use as an error description. +// It strips file:line prefixes from Go's testing package and extracts the +// meaningful content from verbose testify-style error messages (keeping +// only "Error:" and "Messages:" sections). +func extractErrorOutput(output string) string { + // First try to extract testify-style structured errors. + if cleaned := cleanTestifyMessage(output); cleaned != "" { + return cleaned + } + + // Fall back to simple line-by-line cleanup. + var lines []string + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + // Strip " sample_test.go:15: " prefix. + if idx := strings.Index(trimmed, ": "); idx != -1 { + prefix := trimmed[:idx] + if strings.Contains(prefix, ".go:") { + trimmed = trimmed[idx+2:] + } + } + lines = append(lines, trimmed) + } + return strings.Join(lines, "\n") +} + +// cleanTestifyMessage extracts the meaningful content from verbose +// testify-style error messages. It keeps only "Error:" and "Messages:" +// sections, stripping "Error Trace:" and "Test:" sections. +// +// Note: Go's testing.decorate adds 4 spaces of indentation to +// continuation lines of multi-line messages, so lines may be +// prefixed with spaces before the tab characters. +func cleanTestifyMessage(msg string) string { + if !strings.Contains(msg, "\tError:") { + return "" + } + + lines := strings.Split(msg, "\n") + var result []string + inWanted := false + found := false + + for _, line := range lines { + // Strip leading spaces added by Go's testing.decorate. + line = strings.TrimLeft(line, " ") + + // Section headers look like: \t:\t + if len(line) > 1 && line[0] == '\t' && line[1] != ' ' && line[1] != '\t' { + inWanted = false + rest := line[1:] + colonIdx := strings.Index(rest, ":") + if colonIdx < 0 { + continue + } + name := rest[:colonIdx] + after := strings.TrimLeft(rest[colonIdx+1:], " ") + if len(after) == 0 || after[0] != '\t' { + continue + } + if name == "Error" || name == "Messages" { + inWanted = true + found = true + if v := strings.TrimSpace(after[1:]); v != "" { + result = append(result, v) + } + } + continue + } + + // Continuation lines look like: \t\t + if inWanted && len(line) > 0 && line[0] == '\t' { + rest := strings.TrimLeft(line[1:], " ") + if len(rest) > 0 && rest[0] == '\t' { + if v := strings.TrimSpace(rest[1:]); v != "" { + result = append(result, v) + } + } + } + } + + if found && len(result) > 0 { + return strings.Join(result, "\n") + } + return "" +} diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go new file mode 100644 index 0000000..1be220a --- /dev/null +++ b/gotest/gotest_test.go @@ -0,0 +1,411 @@ +package gotest_test + +import ( + "bytes" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "github.com/dagger/otel-go/gotest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" +) + +func runFixture(t *testing.T) []sdktrace.ReadOnlySpan { + t.Helper() + + f, err := os.Open("testdata/sample.jsonl") + require.NoError(t, err) + defer f.Close() + + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + ctx := t.Context() + err = gotest.Run(ctx, f, tp) + require.NoError(t, err) + + return spanRecorder.Ended() +} + +func findSpan(spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + for _, s := range spans { + if s.Name() == name { + return s + } + } + return nil +} + +func findSpanByTestCaseName(spans []sdktrace.ReadOnlySpan, testName string) sdktrace.ReadOnlySpan { + for _, s := range spans { + if spanAttr(s, semconv.TestCaseNameKey).AsString() == testName { + return s + } + } + return nil +} + +func spanNames(spans []sdktrace.ReadOnlySpan) []string { + names := make([]string, 0, len(spans)) + for _, s := range spans { + names = append(names, s.Name()) + } + return names +} + +func runEvents(t *testing.T, events []gotest.TestEvent) []sdktrace.ReadOnlySpan { + t.Helper() + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + for _, ev := range events { + require.NoError(t, enc.Encode(ev)) + } + + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + err := gotest.Run(t.Context(), &buf, tp) + require.NoError(t, err) + + return spanRecorder.Ended() +} + +func nestedTestEvents(pkg string, chain []string, leaf string) []gotest.TestEvent { + now := time.Now() + events := []gotest.TestEvent{{Time: now, Action: "start", Package: pkg}} + + var full string + ancestors := make([]string, 0, len(chain)) + for _, name := range chain { + if full == "" { + full = name + } else { + full += "/" + name + } + ancestors = append(ancestors, full) + events = append(events, gotest.TestEvent{Time: now, Action: "run", Package: pkg, Test: full}) + } + + fullLeaf := full + "/" + leaf + events = append(events, + gotest.TestEvent{Time: now, Action: "run", Package: pkg, Test: fullLeaf}, + gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: pkg, Test: fullLeaf}, + ) + + for i := len(ancestors) - 1; i >= 0; i-- { + events = append(events, gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: pkg, Test: ancestors[i]}) + } + return append(events, gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: pkg}) +} + +func spanAttr(span sdktrace.ReadOnlySpan, key attribute.Key) attribute.Value { + for _, a := range span.Attributes() { + if a.Key == key { + return a.Value + } + } + return attribute.Value{} +} + +func TestPassingTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestPass") + require.NotNil(t, span, "expected span for TestPass") + + assert.Equal(t, codes.Ok, span.Status().Code) + + // semconv attributes + assert.Equal(t, "TestPass", + spanAttr(span, semconv.TestCaseNameKey).AsString()) + + // result status + assert.Contains(t, span.Attributes(), semconv.TestCaseResultStatusPass) + + // duration should be > 0 (test sleeps 10ms) + assert.True(t, span.EndTime().After(span.StartTime())) +} + +func TestFailingTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestFail") + require.NotNil(t, span, "expected span for TestFail") + + assert.Equal(t, codes.Error, span.Status().Code) + assert.Contains(t, span.Status().Description, "something went wrong") + + // result status + assert.Contains(t, span.Attributes(), semconv.TestSuiteRunStatusFailure) +} + +func TestSkippedTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestSkip") + require.NotNil(t, span, "expected span for TestSkip") + + assert.Equal(t, codes.Ok, span.Status().Code) + + // result status + assert.Contains(t, span.Attributes(), semconv.TestSuiteRunStatusSkipped) +} + +func TestSubtestNesting(t *testing.T) { + spans := runFixture(t) + + parent := findSpan(spans, "TestSub") + require.NotNil(t, parent, "expected span for TestSub") + + child := findSpan(spans, "level1") + require.NotNil(t, child, "expected span for level1") + + grandchild := findSpan(spans, "level2") + require.NotNil(t, grandchild, "expected span for level2") + + // Verify parent-child relationships + assert.Equal(t, parent.SpanContext().SpanID(), child.Parent().SpanID(), + "level1 should be a child of TestSub") + assert.Equal(t, child.SpanContext().SpanID(), grandchild.Parent().SpanID(), + "level2 should be a child of level1") + + // Span names should be base names + assert.Equal(t, "TestSub", parent.Name()) + assert.Equal(t, "level1", child.Name()) + assert.Equal(t, "level2", grandchild.Name()) + + // But test.case.name should be the full path + assert.Equal(t, "TestSub/level1", + spanAttr(child, semconv.TestCaseNameKey).AsString()) + assert.Equal(t, "TestSub/level1/level2", + spanAttr(grandchild, semconv.TestCaseNameKey).AsString()) +} + +// Subtest leaf names can themselves contain '/'. For example, a parent test +// may call t.Run("https://github.com/dagger/dagger.git:", ...) or +// t.Run("sub/a_-_../b", ...). Those embedded slashes are part of the leaf +// name, not extra levels of nesting. +func TestSubtestLeafNamesContainingSlashes(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + chain []string + leaf string + }{ + { + name: "url-like leaf with .git:", + chain: []string{"TestCall", "TestArgTypes", "directory_arg_inputs", "git_dir"}, + leaf: "https://github.com/dagger/dagger.git:", + }, + { + name: "url-like leaf with .git:.changes", + chain: []string{"TestCall", "TestArgTypes", "directory_arg_inputs", "git_dir"}, + leaf: "https://github.com/dagger/dagger.git:.changes", + }, + { + name: "url-like leaf with :", + chain: []string{"TestCall", "TestArgTypes", "directory_arg_inputs", "git_dir"}, + leaf: "https://github.com/dagger/dagger:", + }, + { + name: "url-like leaf with :.changes", + chain: []string{"TestCall", "TestArgTypes", "directory_arg_inputs", "git_dir"}, + leaf: "https://github.com/dagger/dagger:.changes", + }, + { + name: "prefixed dot segment leaf", + chain: []string{"TestShell", "TestDirectoryFlag"}, + leaf: "._-_sub/a", + }, + { + name: "suffix dot segment leaf", + chain: []string{"TestShell", "TestDirectoryFlag"}, + leaf: "sub/a_-_.", + }, + { + name: "parent traversal leaf", + chain: []string{"TestShell", "TestDirectoryFlag"}, + leaf: "sub/a_-_../b", + }, + { + name: "double parent traversal leaf", + chain: []string{"TestShell", "TestDirectoryFlag"}, + leaf: "sub/a_-_../../ab", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + spans := runEvents(t, nestedTestEvents("example.com/pkg", tc.chain, tc.leaf)) + + parent := findSpan(spans, tc.chain[len(tc.chain)-1]) + require.NotNil(t, parent, "expected span for parent %q", tc.chain[len(tc.chain)-1]) + + leaf := findSpan(spans, tc.leaf) + require.NotNilf(t, leaf, "expected span name to preserve full leaf %q; got spans %v", tc.leaf, spanNames(spans)) + + assert.Equal(t, parent.SpanContext().SpanID(), leaf.Parent().SpanID(), + "leaf span should stay nested under %q even when its name contains '/'", tc.chain[len(tc.chain)-1]) + + fullName := strings.Join(append(append([]string{}, tc.chain...), tc.leaf), "/") + assert.Equal(t, fullName, spanAttr(leaf, semconv.TestCaseNameKey).AsString()) + }) + } +} + +func TestSiblingLeafCanContainAnotherSubtestName(t *testing.T) { + t.Parallel() + + now := time.Now() + spans := runEvents(t, []gotest.TestEvent{ + {Time: now, Action: "start", Package: "example.com/pkg"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestAmbig"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestAmbig/B"}, + {Time: now, Action: "pause", Package: "example.com/pkg", Test: "TestAmbig/B"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestAmbig/B/C"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestAmbig/B/C"}, + {Time: now.Add(time.Second), Action: "cont", Package: "example.com/pkg", Test: "TestAmbig/B"}, + {Time: now.Add(2 * time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestAmbig/B"}, + {Time: now.Add(2 * time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestAmbig"}, + {Time: now.Add(2 * time.Second), Action: "pass", Package: "example.com/pkg"}, + }) + + parent := findSpanByTestCaseName(spans, "TestAmbig") + require.NotNil(t, parent) + + b := findSpanByTestCaseName(spans, "TestAmbig/B") + require.NotNil(t, b) + assert.Equal(t, "B", b.Name()) + assert.Equal(t, parent.SpanContext().SpanID(), b.Parent().SpanID()) + + bc := findSpanByTestCaseName(spans, "TestAmbig/B/C") + require.NotNil(t, bc) + assert.Equal(t, "B/C", bc.Name()) + assert.Equal(t, parent.SpanContext().SpanID(), bc.Parent().SpanID(), + "TestAmbig/B/C should be a sibling of TestAmbig/B, not its child") +} + +func TestInterleavedParallelSubtestsPreserveParents(t *testing.T) { + t.Parallel() + + now := time.Now() + spans := runEvents(t, []gotest.TestEvent{ + {Time: now, Action: "start", Package: "example.com/pkg"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestPar"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestPar/A"}, + {Time: now, Action: "pause", Package: "example.com/pkg", Test: "TestPar/A"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestPar/B"}, + {Time: now, Action: "pause", Package: "example.com/pkg", Test: "TestPar/B"}, + {Time: now, Action: "cont", Package: "example.com/pkg", Test: "TestPar/A"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestPar/A/child"}, + {Time: now, Action: "cont", Package: "example.com/pkg", Test: "TestPar/B"}, + {Time: now, Action: "run", Package: "example.com/pkg", Test: "TestPar/B/child"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestPar/A/child"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestPar/A"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestPar/B/child"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestPar/B"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestPar"}, + {Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}, + }) + + a := findSpanByTestCaseName(spans, "TestPar/A") + require.NotNil(t, a) + b := findSpanByTestCaseName(spans, "TestPar/B") + require.NotNil(t, b) + + aChild := findSpanByTestCaseName(spans, "TestPar/A/child") + require.NotNil(t, aChild) + assert.Equal(t, "child", aChild.Name()) + assert.Equal(t, a.SpanContext().SpanID(), aChild.Parent().SpanID()) + + bChild := findSpanByTestCaseName(spans, "TestPar/B/child") + require.NotNil(t, bChild) + assert.Equal(t, "child", bChild.Name()) + assert.Equal(t, b.SpanContext().SpanID(), bChild.Parent().SpanID()) +} + +func TestPackageSpan(t *testing.T) { + spans := runFixture(t) + + pkg := findSpan(spans, "github.com/dagger/otel-go/gotest/testdata/sample") + require.NotNil(t, pkg, "expected package span") + + // Package has a failure so it should be marked as error. + assert.Equal(t, codes.Error, pkg.Status().Code) + assert.Contains(t, pkg.Attributes(), semconv.TestSuiteRunStatusFailure) + + // Top-level tests should be children of the package span. + pass := findSpan(spans, "TestPass") + require.NotNil(t, pass) + assert.Equal(t, pkg.SpanContext().SpanID(), pass.Parent().SpanID(), + "TestPass should be a child of the package span") +} + +func TestParallelTests(t *testing.T) { + spans := runFixture(t) + + parent := findSpan(spans, "TestParallel") + require.NotNil(t, parent, "expected span for TestParallel") + + a := findSpan(spans, "a") + require.NotNil(t, a, "expected span for parallel/a") + + b := findSpan(spans, "b") + require.NotNil(t, b, "expected span for parallel/b") + + // Both should be children of TestParallel + assert.Equal(t, parent.SpanContext().SpanID(), a.Parent().SpanID()) + assert.Equal(t, parent.SpanContext().SpanID(), b.Parent().SpanID()) + + // Both should pass + assert.Equal(t, codes.Ok, a.Status().Code) + assert.Equal(t, codes.Ok, b.Status().Code) +} + +func TestSkippedPackage(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + // Simulate what go test -json emits for a package with no test files: + // {"Action":"start","Package":"example.com/nopkg"} + // {"Action":"output","Package":"example.com/nopkg","Output":"? \texample.com/nopkg\t[no test files]\n"} + // {"Action":"skip","Package":"example.com/nopkg"} + events := `{"Time":"2025-01-01T00:00:00Z","Action":"start","Package":"example.com/nopkg"} +{"Time":"2025-01-01T00:00:00Z","Action":"output","Package":"example.com/nopkg","Output":"? \texample.com/nopkg\t[no test files]\n"} +{"Time":"2025-01-01T00:00:00Z","Action":"skip","Package":"example.com/nopkg"} +` + + err := gotest.Run(t.Context(), strings.NewReader(events), tp) + require.NoError(t, err) + + spans := spanRecorder.Ended() + require.Len(t, spans, 1, "expected the skipped package span to be ended") + + pkg := spans[0] + assert.Equal(t, "example.com/nopkg", pkg.Name()) + assert.Equal(t, codes.Ok, pkg.Status().Code) + assert.Contains(t, pkg.Attributes(), semconv.TestSuiteRunStatusSkipped) +} + +func TestSpanCount(t *testing.T) { + spans := runFixture(t) + + // 1 package + + // TestPass, TestFail, TestSkip, + // TestSub, TestSub/level1, TestSub/level1/level2, + // TestParallel, TestParallel/a, TestParallel/b + // = 10 total + assert.Len(t, spans, 10) +} diff --git a/gotest/integration_test.go b/gotest/integration_test.go new file mode 100644 index 0000000..4010467 --- /dev/null +++ b/gotest/integration_test.go @@ -0,0 +1,463 @@ +package gotest_test + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "path/filepath" + "runtime/debug" + "strings" + "testing" + "time" + + "github.com/dagger/otel-go/gotest" + "github.com/dagger/otel-go/oteltestctx" + "github.com/dagger/testctx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +// testBinaryPackage returns the import path of the package under test, +// matching what oteltestctx.detectTestPackage() returns at runtime. +func testBinaryPackage() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + return strings.TrimSuffix(bi.Path, ".test") +} + +// TestSpanContextPropagation is an integration test that verifies the full +// cross-process span context flow: +// +// 1. gotest.Run creates a test span and registers it in the registry +// 2. A socket client (simulating oteltest) requests the span context +// 3. The client creates a child span using the remote span context +// 4. The child span is correctly parented under the test span +func TestSpanContextPropagation(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + socketPath := filepath.Join(t.TempDir(), "span.sock") + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err) + defer listener.Close() + + // Start a socket server mirroring otelgotest's serveSpanContexts. + go serveSpanContexts(listener, registry) + + // Feed JSON events to gotest.Run via a pipe. + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp, + gotest.WithSpanContextRegistry(registry), + ) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: "example.com/pkg"}) + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestFoo"}) + + // Simulate what oteltest does: connect to the socket and retrieve + // the span context for TestFoo. The socket protocol uses + // package-qualified names ("package/TestName"). + remoteSC := requestSpanContext(t, socketPath, "example.com/pkg/TestFoo") + + // Create a child span using the remote span context, simulating + // an instrumented operation inside the test (e.g. a Dagger call). + remoteCtx := trace.ContextWithRemoteSpanContext(context.Background(), remoteSC) + _, childSpan := tp.Tracer("test-instrumentation").Start(remoteCtx, "child-operation") + childSpan.End() + + // Finish the test. + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestFoo"}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) + + spans := spanRecorder.Ended() + + testSpan := findSpan(spans, "TestFoo") + require.NotNil(t, testSpan, "expected TestFoo span") + + childSpanRO := findSpan(spans, "child-operation") + require.NotNil(t, childSpanRO, "expected child-operation span") + + // The child should descend from the test span. + assert.Equal(t, testSpan.SpanContext().TraceID(), childSpanRO.SpanContext().TraceID(), + "child should share trace ID with test span") + assert.Equal(t, testSpan.SpanContext().SpanID(), childSpanRO.Parent().SpanID(), + "child should be parented under test span") +} + +// TestSpanContextPropagationSubtest verifies that subtests also get the +// correct span context, parented under their parent test's span. +func TestSpanContextPropagationSubtest(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + socketPath := filepath.Join(t.TempDir(), "span.sock") + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err) + defer listener.Close() + + go serveSpanContexts(listener, registry) + + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp, + gotest.WithSpanContextRegistry(registry), + ) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: "example.com/pkg"}) + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestParent"}) + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestParent/sub"}) + + // Retrieve span contexts for both the parent and subtest. + // Socket protocol uses package-qualified names. + parentSC := requestSpanContext(t, socketPath, "example.com/pkg/TestParent") + subSC := requestSpanContext(t, socketPath, "example.com/pkg/TestParent/sub") + + // Create child operations under each. + parentCtx := trace.ContextWithRemoteSpanContext(context.Background(), parentSC) + _, parentChild := tp.Tracer("test").Start(parentCtx, "parent-op") + parentChild.End() + + subCtx := trace.ContextWithRemoteSpanContext(context.Background(), subSC) + _, subChild := tp.Tracer("test").Start(subCtx, "sub-op") + subChild.End() + + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestParent/sub"}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestParent"}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) + + spans := spanRecorder.Ended() + + parentSpan := findSpan(spans, "TestParent") + require.NotNil(t, parentSpan) + subSpan := findSpan(spans, "sub") + require.NotNil(t, subSpan) + parentOp := findSpan(spans, "parent-op") + require.NotNil(t, parentOp) + subOp := findSpan(spans, "sub-op") + require.NotNil(t, subOp) + + // All spans should share a trace ID. + traceID := parentSpan.SpanContext().TraceID() + assert.Equal(t, traceID, subSpan.SpanContext().TraceID()) + assert.Equal(t, traceID, parentOp.SpanContext().TraceID()) + assert.Equal(t, traceID, subOp.SpanContext().TraceID()) + + // Subtest span should be a child of the parent test span. + assert.Equal(t, parentSpan.SpanContext().SpanID(), subSpan.Parent().SpanID(), + "subtest should be a child of parent test") + + // In-process operations should be children of their respective test spans. + assert.Equal(t, parentSpan.SpanContext().SpanID(), parentOp.Parent().SpanID(), + "parent-op should be a child of TestParent") + assert.Equal(t, subSpan.SpanContext().SpanID(), subOp.Parent().SpanID(), + "sub-op should be a child of TestParent/sub") +} + +// TestSpanContextPropagationWaitFor verifies that the socket blocks +// until the span is registered, even when the client connects before +// gotest.Run has processed the "run" event. +func TestSpanContextPropagationWaitFor(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + socketPath := filepath.Join(t.TempDir(), "span.sock") + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err) + defer listener.Close() + + go serveSpanContexts(listener, registry) + + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp, + gotest.WithSpanContextRegistry(registry), + ) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: "example.com/pkg"}) + + // Connect to the socket BEFORE writing the "run" event. + // The handler should block until the span is registered. + result := make(chan trace.SpanContext, 1) + go func() { + result <- requestSpanContext(t, socketPath, "example.com/pkg/TestLate") + }() + + // Give the socket client time to connect and block. + time.Sleep(50 * time.Millisecond) + + // Now write the "run" event — this should unblock the socket handler. + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestLate"}) + + select { + case sc := <-result: + assert.True(t, sc.IsValid(), "expected valid span context") + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for span context") + } + + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: "TestLate"}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) +} + +// TestSpanContextWithMiddleware is a full end-to-end test using the real +// oteltestctx.WithTracing middleware. It verifies that when OTEL_TEST_SOCKET +// is set, the middleware adopts the external span context and downstream +// spans are correctly parented. +func TestSpanContextWithMiddleware(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + socketPath := filepath.Join(t.TempDir(), "span.sock") + listener, err := net.Listen("unix", socketPath) + require.NoError(t, err) + defer listener.Close() + + go serveSpanContexts(listener, registry) + + // Set the socket env var before creating the middleware so it picks it up. + t.Setenv("OTEL_TEST_SOCKET", socketPath) + + // The middleware detects the package via debug.ReadBuildInfo(), so the + // synthetic JSON events must use the same package path for the + // package-qualified registry keys to match. + pkg := testBinaryPackage() + require.NotEmpty(t, pkg, "could not detect test binary package") + + // Feed synthetic JSON events to gotest.Run. + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp, + gotest.WithSpanContextRegistry(registry), + ) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: pkg}) + + // Write the "run" event matching the test name that testctx will produce. + fullTestName := t.Name() + "/inner" + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: pkg, Test: fullTestName}) + + // Run the real middleware. WithTracing sees OTEL_TEST_SOCKET, connects + // to the socket, and adopts the span context from gotest.Run. + testctx.New(t, oteltestctx.WithTracing[*testing.T]()).Run("inner", func(ctx context.Context, t *testctx.T) { + // Create a downstream span — this should be a child of the + // gotest-created test span via the adopted remote context. + _, span := tp.Tracer("app").Start(ctx, "downstream-call") + span.End() + }) + + // Finish the synthetic test events. + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: pkg, Test: fullTestName}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: pkg}) + pw.Close() + + require.NoError(t, <-done) + + // Find the spans. + spans := spanRecorder.Ended() + + gotestSpan := findSpanByTestCaseName(spans, fullTestName) + require.NotNil(t, gotestSpan, "expected gotest span for %q", fullTestName) + + downstreamSpan := findSpan(spans, "downstream-call") + require.NotNil(t, downstreamSpan, "expected downstream-call span") + + // The downstream span should be a child of the gotest span. + assert.Equal(t, gotestSpan.SpanContext().TraceID(), downstreamSpan.SpanContext().TraceID(), + "downstream should share trace ID with gotest span") + assert.Equal(t, gotestSpan.SpanContext().SpanID(), downstreamSpan.Parent().SpanID(), + "downstream should be parented under gotest span") +} + +// TestErrorMessageCleaning verifies that verbose testify-style error +// messages in test output are cleaned up for the span status description. +func TestErrorMessageCleaning(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: "example.com/pkg"}) + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestVerbose"}) + + // Simulate testify-formatted error output as test2json would emit it. + // Go's testing.decorate adds " file.go:line: " to the first line + // and 4 spaces of indentation to continuation lines. + for _, line := range []string{ + " test.go:42: \n", + " \tError Trace:\t/src/test.go:42\n", + " \t \t\t\t/src/helper.go:10\n", + " \tError: \tExpected nil, but got error\n", + " \tTest: \tTestVerbose\n", + } { + enc.Encode(gotest.TestEvent{Time: now, Action: "output", Package: "example.com/pkg", Test: "TestVerbose", Output: line}) + } + + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "fail", Package: "example.com/pkg", Test: "TestVerbose", Elapsed: 0.01}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "fail", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) + + spans := spanRecorder.Ended() + span := findSpan(spans, "TestVerbose") + require.NotNil(t, span) + + assert.Equal(t, codes.Error, span.Status().Code) + assert.Equal(t, "Expected nil, but got error", span.Status().Description, + "span status should contain only the cleaned error, not the full trace") +} + +// TestErrorMessageCleaningWithNoise verifies that when testify errors are +// mixed with regular log output, only the error content appears in the +// span status. +func TestErrorMessageCleaningWithNoise(t *testing.T) { + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + pr, pw := io.Pipe() + + done := make(chan error, 1) + go func() { + done <- gotest.Run(t.Context(), pr, tp) + }() + + now := time.Now() + enc := json.NewEncoder(pw) + enc.Encode(gotest.TestEvent{Time: now, Action: "start", Package: "example.com/pkg"}) + enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestNoisy"}) + + // Mix of regular log output and testify error. + for _, line := range []string{ + " test.go:10: some log output\n", + " test.go:20: more log output\n", + " test.go:42: \n", + " \tError Trace:\t/src/test.go:42\n", + " \tError: \tCondition never satisfied\n", + " \tTest: \tTestNoisy\n", + " test.go:50: cleanup: exit status 1\n", + " test.go:60: server: shutting down\n", + } { + enc.Encode(gotest.TestEvent{Time: now, Action: "output", Package: "example.com/pkg", Test: "TestNoisy", Output: line}) + } + + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "fail", Package: "example.com/pkg", Test: "TestNoisy", Elapsed: 0.01}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "fail", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) + + spans := spanRecorder.Ended() + span := findSpan(spans, "TestNoisy") + require.NotNil(t, span) + + assert.Equal(t, codes.Error, span.Status().Code) + assert.Equal(t, "Condition never satisfied", span.Status().Description, + "span status should contain only the cleaned error, not log noise") +} + +// --- helpers --- + +// serveSpanContexts mirrors otelgotest's socket server. +func serveSpanContexts(listener net.Listener, registry *gotest.SpanContextRegistry) { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + sc, ok := registry.WaitFor(ctx, scanner.Text()) + if !ok { + return + } + fmt.Fprintf(conn, "00-%s-%s-%s\n", sc.TraceID(), sc.SpanID(), sc.TraceFlags()) + }() + } +} + +// requestSpanContext connects to the socket and retrieves a span context. +func requestSpanContext(t *testing.T, socketPath, testName string) trace.SpanContext { + t.Helper() + conn, err := net.DialTimeout("unix", socketPath, 5*time.Second) + require.NoError(t, err) + defer conn.Close() + conn.SetDeadline(time.Now().Add(10 * time.Second)) + + fmt.Fprintln(conn, testName) + + scanner := bufio.NewScanner(conn) + require.True(t, scanner.Scan(), "expected traceparent response from socket") + + prop := propagation.TraceContext{} + ctx := prop.Extract(context.Background(), propagation.MapCarrier{ + "traceparent": scanner.Text(), + }) + sc := trace.SpanContextFromContext(ctx) + require.True(t, sc.IsValid(), "invalid traceparent: %s", scanner.Text()) + return sc +} diff --git a/gotest/registry.go b/gotest/registry.go new file mode 100644 index 0000000..9ff6399 --- /dev/null +++ b/gotest/registry.go @@ -0,0 +1,80 @@ +package gotest + +import ( + "context" + "sync" + + "go.opentelemetry.io/otel/trace" +) + +// SpanContextRegistry allows external coordinators to retrieve span contexts +// for test spans created by [Run]. This enables cross-process span context +// propagation: an external process creates the span, and the test binary +// retrieves its context via a sideband (e.g., Unix socket) so that +// in-process operations become children of the externally created span. +type SpanContextRegistry struct { + mu sync.Mutex + spans map[string]trace.SpanContext + waiters map[string][]chan trace.SpanContext + closed bool +} + +// NewSpanContextRegistry creates a new SpanContextRegistry. +func NewSpanContextRegistry() *SpanContextRegistry { + return &SpanContextRegistry{ + spans: make(map[string]trace.SpanContext), + waiters: make(map[string][]chan trace.SpanContext), + } +} + +// Register stores a span context for the given test name and unblocks +// any goroutines waiting for it via [WaitFor]. +func (r *SpanContextRegistry) Register(testName string, sc trace.SpanContext) { + r.mu.Lock() + defer r.mu.Unlock() + r.spans[testName] = sc + if waiters, ok := r.waiters[testName]; ok { + for _, ch := range waiters { + ch <- sc + } + delete(r.waiters, testName) + } +} + +// WaitFor blocks until a span context is registered for the given test name, +// the context is canceled, or the registry is closed. Returns the span context +// and true on success, or a zero span context and false otherwise. +func (r *SpanContextRegistry) WaitFor(ctx context.Context, testName string) (trace.SpanContext, bool) { + r.mu.Lock() + if sc, ok := r.spans[testName]; ok { + r.mu.Unlock() + return sc, true + } + if r.closed { + r.mu.Unlock() + return trace.SpanContext{}, false + } + ch := make(chan trace.SpanContext, 1) + r.waiters[testName] = append(r.waiters[testName], ch) + r.mu.Unlock() + + select { + case sc, ok := <-ch: + return sc, ok + case <-ctx.Done(): + return trace.SpanContext{}, false + } +} + +// Close unblocks all pending WaitFor calls. +func (r *SpanContextRegistry) Close() { + r.mu.Lock() + defer r.mu.Unlock() + r.closed = true + for _, waiters := range r.waiters { + for _, ch := range waiters { + close(ch) + } + } + r.waiters = nil +} diff --git a/gotest/testdata/sample.jsonl b/gotest/testdata/sample.jsonl new file mode 100644 index 0000000..5b457b2 --- /dev/null +++ b/gotest/testdata/sample.jsonl @@ -0,0 +1,55 @@ +{"Time":"2026-04-06T22:29:47.171257724-04:00","Action":"start","Package":"github.com/dagger/otel-go/gotest/testdata/sample"} +{"Time":"2026-04-06T22:29:47.172671915-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestPass"} +{"Time":"2026-04-06T22:29:47.17269584-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestPass","Output":"=== RUN TestPass\n"} +{"Time":"2026-04-06T22:29:47.182860694-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestPass","Output":" sample_test.go:10: this test passes\n"} +{"Time":"2026-04-06T22:29:47.182919194-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestPass","Output":"--- PASS: TestPass (0.01s)\n"} +{"Time":"2026-04-06T22:29:47.182926418-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestPass","Elapsed":0.01} +{"Time":"2026-04-06T22:29:47.182945424-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestFail"} +{"Time":"2026-04-06T22:29:47.182947698-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestFail","Output":"=== RUN TestFail\n"} +{"Time":"2026-04-06T22:29:47.192960815-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestFail","Output":" sample_test.go:15: something went wrong\n"} +{"Time":"2026-04-06T22:29:47.192986083-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestFail","Output":"--- FAIL: TestFail (0.01s)\n"} +{"Time":"2026-04-06T22:29:47.192992235-04:00","Action":"fail","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestFail","Elapsed":0.01} +{"Time":"2026-04-06T22:29:47.192998466-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSkip"} +{"Time":"2026-04-06T22:29:47.19300023-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSkip","Output":"=== RUN TestSkip\n"} +{"Time":"2026-04-06T22:29:47.193003666-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSkip","Output":" sample_test.go:19: not implemented yet\n"} +{"Time":"2026-04-06T22:29:47.193005981-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSkip","Output":"--- SKIP: TestSkip (0.00s)\n"} +{"Time":"2026-04-06T22:29:47.193007974-04:00","Action":"skip","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSkip","Elapsed":0} +{"Time":"2026-04-06T22:29:47.193009948-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub"} +{"Time":"2026-04-06T22:29:47.193011291-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub","Output":"=== RUN TestSub\n"} +{"Time":"2026-04-06T22:29:47.193014797-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1"} +{"Time":"2026-04-06T22:29:47.19301607-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1","Output":"=== RUN TestSub/level1\n"} +{"Time":"2026-04-06T22:29:47.193017593-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1","Output":" sample_test.go:24: in level1\n"} +{"Time":"2026-04-06T22:29:47.193020568-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1/level2"} +{"Time":"2026-04-06T22:29:47.193021801-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1/level2","Output":"=== RUN TestSub/level1/level2\n"} +{"Time":"2026-04-06T22:29:47.198200299-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1/level2","Output":" sample_test.go:27: in level2\n"} +{"Time":"2026-04-06T22:29:47.198233371-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1/level2","Output":"--- PASS: TestSub/level1/level2 (0.01s)\n"} +{"Time":"2026-04-06T22:29:47.198267536-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1/level2","Elapsed":0.01} +{"Time":"2026-04-06T22:29:47.198272335-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1","Output":"--- PASS: TestSub/level1 (0.01s)\n"} +{"Time":"2026-04-06T22:29:47.198274409-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub/level1","Elapsed":0.01} +{"Time":"2026-04-06T22:29:47.198283005-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub","Output":"--- PASS: TestSub (0.01s)\n"} +{"Time":"2026-04-06T22:29:47.198285019-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestSub","Elapsed":0.01} +{"Time":"2026-04-06T22:29:47.198287433-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel"} +{"Time":"2026-04-06T22:29:47.19829071-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel","Output":"=== RUN TestParallel\n"} +{"Time":"2026-04-06T22:29:47.198292693-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T22:29:47.198294136-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Output":"=== RUN TestParallel/a\n"} +{"Time":"2026-04-06T22:29:47.198295869-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Output":"=== PAUSE TestParallel/a\n"} +{"Time":"2026-04-06T22:29:47.198297052-04:00","Action":"pause","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T22:29:47.198298735-04:00","Action":"run","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T22:29:47.198299887-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Output":"=== RUN TestParallel/b\n"} +{"Time":"2026-04-06T22:29:47.19830139-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Output":"=== PAUSE TestParallel/b\n"} +{"Time":"2026-04-06T22:29:47.198302532-04:00","Action":"pause","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T22:29:47.198303995-04:00","Action":"cont","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T22:29:47.198305177-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Output":"=== CONT TestParallel/a\n"} +{"Time":"2026-04-06T22:29:47.198313863-04:00","Action":"cont","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T22:29:47.19831759-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Output":"=== CONT TestParallel/b\n"} +{"Time":"2026-04-06T22:29:47.218444966-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Output":" sample_test.go:36: parallel a done\n"} +{"Time":"2026-04-06T22:29:47.218457891-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Output":" sample_test.go:41: parallel b done\n"} +{"Time":"2026-04-06T22:29:47.21846286-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Output":"--- PASS: TestParallel/a (0.02s)\n"} +{"Time":"2026-04-06T22:29:47.218464713-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/a","Elapsed":0.02} +{"Time":"2026-04-06T22:29:47.218472498-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Output":"--- PASS: TestParallel/b (0.02s)\n"} +{"Time":"2026-04-06T22:29:47.218474462-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel/b","Elapsed":0.02} +{"Time":"2026-04-06T22:29:47.218475895-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel","Output":"--- PASS: TestParallel (0.00s)\n"} +{"Time":"2026-04-06T22:29:47.21848385-04:00","Action":"pass","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Test":"TestParallel","Elapsed":0} +{"Time":"2026-04-06T22:29:47.218485693-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Output":"FAIL\n"} +{"Time":"2026-04-06T22:29:47.21880723-04:00","Action":"output","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Output":"FAIL\tgithub.com/dagger/otel-go/gotest/testdata/sample\t0.047s\n"} +{"Time":"2026-04-06T22:29:47.218823822-04:00","Action":"fail","Package":"github.com/dagger/otel-go/gotest/testdata/sample","Elapsed":0.048} diff --git a/gotest/testdata/sample/go.mod b/gotest/testdata/sample/go.mod new file mode 100644 index 0000000..19257b9 --- /dev/null +++ b/gotest/testdata/sample/go.mod @@ -0,0 +1,3 @@ +module github.com/dagger/otel-go/gotest/testdata/sample + +go 1.26.1 diff --git a/gotest/testdata/sample/sample_test.go b/gotest/testdata/sample/sample_test.go new file mode 100644 index 0000000..5123bb5 --- /dev/null +++ b/gotest/testdata/sample/sample_test.go @@ -0,0 +1,43 @@ +package sample_test + +import ( + "testing" + "time" +) + +func TestPass(t *testing.T) { + time.Sleep(10 * time.Millisecond) + t.Log("this test passes") +} + +func TestFail(t *testing.T) { + time.Sleep(10 * time.Millisecond) + t.Error("something went wrong") +} + +func TestSkip(t *testing.T) { + t.Skip("not implemented yet") +} + +func TestSub(t *testing.T) { + t.Run("level1", func(t *testing.T) { + t.Log("in level1") + t.Run("level2", func(t *testing.T) { + time.Sleep(5 * time.Millisecond) + t.Log("in level2") + }) + }) +} + +func TestParallel(t *testing.T) { + t.Run("a", func(t *testing.T) { + t.Parallel() + time.Sleep(20 * time.Millisecond) + t.Log("parallel a done") + }) + t.Run("b", func(t *testing.T) { + t.Parallel() + time.Sleep(20 * time.Millisecond) + t.Log("parallel b done") + }) +} diff --git a/gotestmain/gotestmain.go b/gotestmain/gotestmain.go new file mode 100644 index 0000000..f5543ea --- /dev/null +++ b/gotestmain/gotestmain.go @@ -0,0 +1,139 @@ +// Package gotestmain provides automatic OTel test reporting via a +// TestMain integration. +// +// Usage: +// +// func TestMain(m *testing.M) { +// os.Exit(gotestmain.Main(m)) +// } +// +// Main captures test output, converts it to OTel spans, and exports +// them via the standard OTEL_* environment variables. If no OTLP +// endpoint is configured, tests run normally with no overhead. +package gotestmain + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + + otel "github.com/dagger/otel-go" + "github.com/dagger/otel-go/gotest" + otelgo "go.opentelemetry.io/otel" +) + +// Main runs the test suite and emits OTel spans for each test. +// It returns the exit code from m.Run(). +// +// Call it from TestMain: +// +// func TestMain(m *testing.M) { +// os.Exit(gotestmain.Main(m)) +// } +func Main(m *testing.M) int { + // If no OTLP endpoint is configured, skip the whole pipeline. + if !hasOTLPEndpoint() { + return m.Run() + } + + ctx := context.Background() + ctx = otel.InitEmbedded(ctx, nil) + defer otel.Close() + + tp := otelgo.GetTracerProvider() + + // Redirect stdout to a pipe so we can capture test2json output. + pipeR, pipeW, err := os.Pipe() + if err != nil { + return m.Run() + } + origStdout := os.Stdout + os.Stdout = pipeW + + // Force -test.v=test2json for structured output. + forceTest2JSON() + + // Detect the package name from the binary name. + pkg := detectPackage() + + // Spawn go tool test2json to convert raw output to JSON. + test2jsonArgs := []string{"tool", "test2json"} + if pkg != "" { + test2jsonArgs = append(test2jsonArgs, "-p", pkg) + } + test2json := exec.Command("go", test2jsonArgs...) + test2json.Stdin = pipeR + test2json.Stderr = os.Stderr + + jsonOut, err := test2json.StdoutPipe() + if err != nil { + os.Stdout = origStdout + pipeW.Close() + pipeR.Close() + return m.Run() + } + + if err := test2json.Start(); err != nil { + os.Stdout = origStdout + pipeW.Close() + pipeR.Close() + return m.Run() + } + + // Process JSON events in a goroutine, passing through + // human-readable output to the original stdout. + done := make(chan error, 1) + go func() { + opts := []gotest.Option{gotest.WithOutput(origStdout)} + if lp := otel.LoggerProvider(ctx); lp != nil { + opts = append(opts, gotest.WithLoggerProvider(lp)) + } + done <- gotest.Run(ctx, jsonOut, tp, opts...) + }() + + // Run the tests. + exitCode := m.Run() + + // Close the write end so test2json gets EOF. + pipeW.Close() + + // Wait for the pipeline to finish. + <-done + test2json.Wait() + + // Restore stdout for any final output. + os.Stdout = origStdout + + return exitCode +} + +// forceTest2JSON replaces any existing -test.v flag with -test.v=test2json. +func forceTest2JSON() { + newArgs := []string{os.Args[0], "-test.v=test2json"} + for _, arg := range os.Args[1:] { + if !strings.HasPrefix(arg, "-test.v") { + newArgs = append(newArgs, arg) + } + } + os.Args = newArgs +} + +// detectPackage tries to determine the package name from the test binary. +func detectPackage() string { + if exe, err := os.Executable(); err == nil { + base := strings.TrimSuffix(exe, ".test") + if idx := strings.LastIndex(base, "/"); idx >= 0 { + return base[idx+1:] + } + return base + } + return "" +} + +// hasOTLPEndpoint returns true if any OTLP trace endpoint is configured. +func hasOTLPEndpoint() bool { + return os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" || + os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" +} diff --git a/gotestmain/gotestmain_test.go b/gotestmain/gotestmain_test.go new file mode 100644 index 0000000..106ec6e --- /dev/null +++ b/gotestmain/gotestmain_test.go @@ -0,0 +1,53 @@ +package gotestmain + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHasOTLPEndpoint(t *testing.T) { + t.Run("none", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + assert.False(t, hasOTLPEndpoint()) + }) + t.Run("traces endpoint", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "http://localhost:4318") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + assert.True(t, hasOTLPEndpoint()) + }) + t.Run("general endpoint", func(t *testing.T) { + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318") + assert.True(t, hasOTLPEndpoint()) + }) +} + +func TestForceTest2JSON(t *testing.T) { + origArgs := make([]string, len(os.Args)) + copy(origArgs, os.Args) + defer func() { os.Args = origArgs }() + + t.Run("adds flag when missing", func(t *testing.T) { + os.Args = []string{"test.bin", "-test.run=TestFoo", "-test.count=1"} + forceTest2JSON() + assert.Contains(t, os.Args, "-test.v=test2json") + assert.Contains(t, os.Args, "-test.run=TestFoo") + assert.Contains(t, os.Args, "-test.count=1") + }) + + t.Run("replaces existing -test.v", func(t *testing.T) { + os.Args = []string{"test.bin", "-test.v=true", "-test.count=1"} + forceTest2JSON() + assert.Contains(t, os.Args, "-test.v=test2json") + assert.NotContains(t, os.Args, "-test.v=true") + }) + + t.Run("replaces bare -test.v", func(t *testing.T) { + os.Args = []string{"test.bin", "-test.v", "-test.count=1"} + forceTest2JSON() + assert.Contains(t, os.Args, "-test.v=test2json") + }) +} diff --git a/junit/junit.go b/junit/junit.go new file mode 100644 index 0000000..0e47020 --- /dev/null +++ b/junit/junit.go @@ -0,0 +1,176 @@ +// Package junit converts JUnit XML test suites into OTel spans. +package junit + +//go:generate sh -c "go-junit-report -parser gojson < ../gotest/testdata/sample.jsonl > testdata/sample.xml" + +import ( + "context" + "io" + "time" + + otel "github.com/dagger/otel-go" + junitparser "github.com/joshdk/go-junit" + "go.opentelemetry.io/otel/codes" + sdklog "go.opentelemetry.io/otel/sdk/log" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" +) + +const instrumentationLibrary = "dagger.io/oteljunit" + +// Option configures the behavior of Run. +type Option func(*runConfig) + +type runConfig struct { + loggerProvider *sdklog.LoggerProvider +} + +// WithLoggerProvider routes test output to each test's span as OTel log +// records via [otel.SpanStdio]. +func WithLoggerProvider(lp *sdklog.LoggerProvider) Option { + return func(c *runConfig) { c.loggerProvider = lp } +} + +// Run parses JUnit XML from r and emits OTel spans for each test. +func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Option) error { + var cfg runConfig + for _, opt := range opts { + opt(&cfg) + } + + suites, err := junitparser.IngestReader(r) + if err != nil { + return err + } + + tracer := tp.Tracer(instrumentationLibrary) + + for _, suite := range suites { + emitSuite(ctx, tracer, &cfg, suite) + } + + return nil +} + +// suiteTimestamp parses the JUnit timestamp attribute from a suite's properties. +func suiteTimestamp(suite junitparser.Suite) time.Time { + ts, ok := suite.Properties["timestamp"] + if !ok { + return time.Time{} + } + // Try RFC3339 first (includes timezone), then the common JUnit format. + for _, layout := range []string{ + time.RFC3339, + "2006-01-02T15:04:05", + } { + if t, err := time.Parse(layout, ts); err == nil { + return t + } + } + return time.Time{} +} + +func emitSuite(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suite junitparser.Suite) { + suiteName := suite.Name + if suiteName == "" { + suiteName = suite.Package + } + if suiteName == "" { + suiteName = "suite" + } + + ts := suiteTimestamp(suite) + + var startOpts []trace.SpanStartOption + startOpts = append(startOpts, trace.WithAttributes( + semconv.TestSuiteName(suiteName), + )) + if !ts.IsZero() { + startOpts = append(startOpts, trace.WithTimestamp(ts)) + } + + suiteCtx, suiteSpan := tracer.Start(ctx, suiteName, startOpts...) + + for _, test := range suite.Tests { + emitTest(suiteCtx, tracer, cfg, suiteName, ts, test) + } + + // Nested suites. + for _, child := range suite.Suites { + emitSuite(suiteCtx, tracer, cfg, child) + } + + // Set suite status based on totals. + if suite.Totals.Failed > 0 || suite.Totals.Error > 0 { + suiteSpan.SetStatus(codes.Error, "suite had failures") + suiteSpan.SetAttributes(semconv.TestSuiteRunStatusFailure) + } else { + suiteSpan.SetStatus(codes.Ok, "") + suiteSpan.SetAttributes(semconv.TestCaseResultStatusPass) + } + + var endOpts []trace.SpanEndOption + if !ts.IsZero() { + endOpts = append(endOpts, trace.WithTimestamp(ts.Add(suite.Totals.Duration))) + } + suiteSpan.End(endOpts...) +} + +func emitTest(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suiteName string, suiteStart time.Time, test junitparser.Test) { + if test.Duration == 0 { + // avoid gotcha; end time has to be .After(startTime) + test.Duration = 1 + } + var startTime, endTime time.Time + if !suiteStart.IsZero() { + startTime = suiteStart + endTime = suiteStart.Add(test.Duration) + } else { + endTime = time.Now() + startTime = endTime.Add(-test.Duration) + } + + spanCtx, span := tracer.Start(ctx, test.Name, + trace.WithTimestamp(startTime), + trace.WithAttributes( + semconv.TestCaseName(test.Name), + semconv.TestSuiteName(suiteName), + ), + ) + + // Emit output as span logs if configured. + if cfg.loggerProvider != nil && (test.SystemOut != "" || test.SystemErr != "") { + logCtx := otel.WithLoggerProvider(spanCtx, cfg.loggerProvider) + streams := otel.SpanStdio(logCtx, instrumentationLibrary) + if test.SystemOut != "" { + io.WriteString(streams.Stdout, test.SystemOut) + } + if test.SystemErr != "" { + io.WriteString(streams.Stderr, test.SystemErr) + } + streams.Close() + } + + switch test.Status { + case junitparser.StatusPassed: + span.SetStatus(codes.Ok, "") + span.SetAttributes(semconv.TestCaseResultStatusPass) + + case junitparser.StatusFailed, junitparser.StatusError: + desc := test.Message + if desc == "" && test.Error != nil { + desc = test.Error.Error() + } + if desc == "" { + desc = "test failed" + } + span.SetStatus(codes.Error, desc) + span.SetAttributes(semconv.TestSuiteRunStatusFailure) + + case junitparser.StatusSkipped: + span.SetStatus(codes.Ok, "") + span.SetAttributes(semconv.TestSuiteRunStatusSkipped) + } + + span.End(trace.WithTimestamp(endTime)) +} diff --git a/junit/junit_test.go b/junit/junit_test.go new file mode 100644 index 0000000..845d8cd --- /dev/null +++ b/junit/junit_test.go @@ -0,0 +1,139 @@ +package junit_test + +import ( + "os" + "testing" + "time" + + "github.com/dagger/otel-go/junit" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" +) + +func runFixture(t *testing.T) []sdktrace.ReadOnlySpan { + t.Helper() + + f, err := os.Open("testdata/sample.xml") + require.NoError(t, err) + defer f.Close() + + spanRecorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + err = junit.Run(t.Context(), f, tp) + require.NoError(t, err) + + return spanRecorder.Ended() +} + +func findSpan(spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + for _, s := range spans { + if s.Name() == name { + return s + } + } + return nil +} + +func spanAttr(span sdktrace.ReadOnlySpan, key attribute.Key) attribute.Value { + for _, a := range span.Attributes() { + if a.Key == key { + return a.Value + } + } + return attribute.Value{} +} + +func TestPassingTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestPass") + require.NotNil(t, span, "expected span for TestPass") + + assert.Equal(t, codes.Ok, span.Status().Code) + assert.Contains(t, span.Attributes(), semconv.TestCaseResultStatusPass) + assert.Equal(t, "TestPass", + spanAttr(span, semconv.TestCaseNameKey).AsString()) + assert.True(t, span.EndTime().After(span.StartTime())) +} + +func TestFailingTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestFail") + require.NotNil(t, span, "expected span for TestFail") + + assert.Equal(t, codes.Error, span.Status().Code) + assert.Contains(t, span.Attributes(), semconv.TestSuiteRunStatusFailure) +} + +func TestSkippedTest(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestSkip") + require.NotNil(t, span, "expected span for TestSkip") + + assert.Equal(t, codes.Ok, span.Status().Code) + assert.Contains(t, span.Attributes(), semconv.TestSuiteRunStatusSkipped) +} + +func TestSuiteSpan(t *testing.T) { + spans := runFixture(t) + + suite := findSpan(spans, "github.com/dagger/otel-go/gotest/testdata/sample") + require.NotNil(t, suite, "expected suite span") + + // Suite has failures so it should be marked as error. + assert.Equal(t, codes.Error, suite.Status().Code) + assert.Contains(t, suite.Attributes(), semconv.TestSuiteRunStatusFailure) + + // All test spans should be children of the suite. + pass := findSpan(spans, "TestPass") + require.NotNil(t, pass) + assert.Equal(t, suite.SpanContext().SpanID(), pass.Parent().SpanID()) +} + +func TestSubtestSpanName(t *testing.T) { + spans := runFixture(t) + + // JUnit flattens subtests as "TestSub/level1/level2". + // Span name should be the raw test name, not simplified. + span := findSpan(spans, "TestSub/level1/level2") + require.NotNil(t, span, "expected span with full name 'TestSub/level1/level2'") + + assert.Equal(t, "TestSub/level1/level2", + spanAttr(span, semconv.TestCaseNameKey).AsString()) +} + +func TestTimestamp(t *testing.T) { + spans := runFixture(t) + + // The sample XML has timestamp="2026-04-06T22:30:03-04:00" on the suite. + expectedStart, err := time.Parse(time.RFC3339, "2026-04-06T22:30:03-04:00") + require.NoError(t, err) + + suite := findSpan(spans, "github.com/dagger/otel-go/gotest/testdata/sample") + require.NotNil(t, suite) + assert.Equal(t, expectedStart, suite.StartTime(), "suite should start at XML timestamp") + + // Test spans should also start at the suite timestamp. + pass := findSpan(spans, "TestPass") + require.NotNil(t, pass) + assert.Equal(t, expectedStart, pass.StartTime(), "test should start at suite timestamp") + + // End time = start + duration (TestPass has time="0.010"). + assert.Equal(t, expectedStart.Add(10*time.Millisecond), pass.EndTime(), + "test end time should be start + duration") +} + +func TestSpanCount(t *testing.T) { + spans := runFixture(t) + + // 1 suite + 9 tests = 10 spans + assert.Len(t, spans, 10) +} diff --git a/junit/testdata/sample.xml b/junit/testdata/sample.xml new file mode 100644 index 0000000..0e97ffe --- /dev/null +++ b/junit/testdata/sample.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/oteltestctx/clean_test.go b/oteltestctx/clean_test.go new file mode 100644 index 0000000..4d6dda5 --- /dev/null +++ b/oteltestctx/clean_test.go @@ -0,0 +1,75 @@ +package oteltestctx + +import ( + "testing" +) + +func TestCleanErrorMessage(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "plain message unchanged", + input: "something went wrong", + want: "something went wrong", + }, + { + name: "whitespace trimmed", + input: " something went wrong ", + want: "something went wrong", + }, + { + name: "testify single-line error", + input: "\n\tError Trace:\t/app/test.go:41\n\tError: \tExpected nil, but got error\n\tTest: \tTestFoo\n", + want: "Expected nil, but got error", + }, + { + name: "testify multi-line error with long trace", + input: "\n\tError Trace:\t/app/core/integration/module_iface_test.go:41\n" + + "\t \t\t\t\t/go/pkg/mod/github.com/dagger/testctx@v0.1.2/testctx.go:296\n" + + "\t \t\t\t\t/go/pkg/mod/github.com/dagger/testctx/oteltest@v0.1.2/log.go:37\n" + + "\t \t\t\t\t/go/pkg/mod/github.com/dagger/testctx/oteltest@v0.1.2/trace.go:94\n" + + "\t \t\t\t\t/go/pkg/mod/github.com/dagger/testctx@v0.1.2/middleware.go:25\n" + + "\t \t\t\t\t/go/pkg/mod/github.com/dagger/testctx@v0.1.2/testctx.go:150\n" + + "\tError: \tReceived unexpected error:\n" + + "\t \texit code: 1 [traceparent:0b47577d7593bdb744a5bbe21b4d7479-30b0c866d891a343]\n" + + "\tTest: \tTestInterface/TestIfaceBasic/go\n", + want: "Received unexpected error:\nexit code: 1 [traceparent:0b47577d7593bdb744a5bbe21b4d7479-30b0c866d891a343]", + }, + { + name: "testify with messages section", + input: "\n\tError Trace:\t/app/test.go:41\n" + + "\tError: \tReceived unexpected error:\n" + + "\t \tsomething failed\n" + + "\tMessages: \tadditional context\n" + + "\tTest: \tTestFoo\n", + want: "Received unexpected error:\nsomething failed\nadditional context", + }, + { + name: "message with tab-Error but not testify format", + input: "some\tError: happened", + want: "some\tError: happened", + }, + { + name: "empty message", + input: "", + want: "", + }, + { + name: "newline-only message", + input: "\n\n", + want: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := cleanErrorMessage(tc.input) + if got != tc.want { + t.Errorf("cleanErrorMessage() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/oteltestctx/failed_test.go b/oteltestctx/failed_test.go new file mode 100644 index 0000000..9423cb2 --- /dev/null +++ b/oteltestctx/failed_test.go @@ -0,0 +1,220 @@ +package oteltestctx_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/dagger/otel-go/oteltestctx" + "github.com/dagger/testctx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +// spanResult holds serialized span data for cross-process verification. +type spanResult struct { + Name string `json:"name"` + StatusCode int `json:"status_code"` + StatusDesc string `json:"status_desc"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +// TestSubprocess is a helper that only runs when invoked as a subprocess. +// It sets up a span recorder, runs failing subtests, and writes span data +// to the file specified by OTELTEST_SPAN_FILE. Individual subtests are +// selected via -test.run filtering. +func TestSubprocess(t *testing.T) { + spanFile := os.Getenv("OTELTEST_SPAN_FILE") + if spanFile == "" { + t.Skip("only run as subprocess") + } + + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t, oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + })) + + t.Cleanup(func() { + spans := spanRecorder.Ended() + var results []spanResult + for _, s := range spans { + results = append(results, spanResult{ + Name: s.Name(), + StatusCode: int(s.Status().Code), + StatusDesc: s.Status().Description, + StartTime: s.StartTime(), + EndTime: s.EndTime(), + }) + } + data, _ := json.Marshal(results) + os.WriteFile(spanFile, data, 0644) + }) + + tt.Run("SingleError", func(ctx context.Context, t *testctx.T) { + t.Error("something went wrong") + }) + + tt.Run("MultipleErrors", func(ctx context.Context, t *testctx.T) { + t.Error("first error") + t.Errorf("second error: %d", 42) + }) + + tt.Run("Fatal", func(ctx context.Context, t *testctx.T) { + t.Fatal("fatal error") + }) + + tt.Run("Fatalf", func(ctx context.Context, t *testctx.T) { + t.Fatalf("fatal: %s", "boom") + }) + + tt.Run("ErrorThenFatal", func(ctx context.Context, t *testctx.T) { + t.Error("non-fatal") + t.Fatal("fatal") + }) + + tt.Run("FailNoMessage", func(ctx context.Context, t *testctx.T) { + t.Fail() + }) + + tt.Run("TestifyRequireNoError", func(ctx context.Context, t *testctx.T) { + require.NoError(t, fmt.Errorf("something failed")) + }) + + tt.Run("ParallelChildFails", func(ctx context.Context, t *testctx.T) { + t.Run("passing", func(ctx context.Context, t *testctx.T) { + t.Unwrap().Parallel() + time.Sleep(200 * time.Millisecond) + }) + t.Run("failing", func(ctx context.Context, t *testctx.T) { + t.Unwrap().Parallel() + time.Sleep(200 * time.Millisecond) + t.Error("child failed") + }) + }) +} + +// runSubprocess invokes the test binary as a subprocess, selecting a specific +// TestSubprocess subtest, and returns the recorded span data. +func runSubprocess(t testing.TB, subtest string) []spanResult { + t.Helper() + + f, err := os.CreateTemp("", "oteltest-spans-*.json") + require.NoError(t, err) + f.Close() + defer os.Remove(f.Name()) + + cmd := exec.Command(os.Args[0], + "-test.run=^TestSubprocess$/^"+subtest+"$", + "-test.count=1", + ) + // Clear OTEL_TEST_SOCKET so the subprocess doesn't try to connect + // to the parent's otelgotest socket — it has its own span recorder. + cmd.Env = append(os.Environ(), "OTELTEST_SPAN_FILE="+f.Name(), "OTEL_TEST_SOCKET=") + cmd.Run() // ignore exit code; the subprocess test is expected to fail + + data, err := os.ReadFile(f.Name()) + require.NoError(t, err) + + var results []spanResult + require.NoError(t, json.Unmarshal(data, &results)) + return results +} + +func TestFailedTestErrorMessages(t *testing.T) { + tests := []struct { + name string + subtest string + wantDesc string // exact match (when set) + check func(t *testing.T, spans []spanResult) + }{ + { + name: "single Error populates span status", + subtest: "SingleError", + wantDesc: "something went wrong", + }, + { + name: "multiple errors joined with newlines", + subtest: "MultipleErrors", + wantDesc: "first error\nsecond error: 42", + }, + { + name: "Fatal populates span status", + subtest: "Fatal", + wantDesc: "fatal error", + }, + { + name: "Fatalf populates span status", + subtest: "Fatalf", + wantDesc: "fatal: boom", + }, + { + name: "Error then Fatal accumulates both", + subtest: "ErrorThenFatal", + wantDesc: "non-fatal\nfatal", + }, + { + name: "Fail with no message falls back to test failed", + subtest: "FailNoMessage", + wantDesc: "test failed", + }, + { + name: "testify verbose error is cleaned", + subtest: "TestifyRequireNoError", + wantDesc: "Received unexpected error:\nsomething failed", + }, + { + name: "parallel child failure reflects on parent", + subtest: "ParallelChildFails", + check: func(t *testing.T, spans []spanResult) { + // Expect 3 spans: passing, failing, and the parent + require.Len(t, spans, 3) + byName := map[string]spanResult{} + for _, s := range spans { + byName[s.Name] = s + } + // The parent span must reflect the child failure + parent := byName["ParallelChildFails"] + assert.Equal(t, int(codes.Error), parent.StatusCode, + "parent span should be marked as failed") + assert.Equal(t, "child failed", parent.StatusDesc) + // The parent span duration should reflect only the sync + // setup phase, not the parallel child execution time. + // Children sleep 200ms; parent should be well under that. + parentDur := parent.EndTime.Sub(parent.StartTime) + assert.Less(t, parentDur, 100*time.Millisecond, + "parent span duration should not include parallel child time") + // The failing child should have the error message + failing := byName["failing"] + assert.Equal(t, int(codes.Error), failing.StatusCode) + assert.Equal(t, "child failed", failing.StatusDesc) + // The passing child should be OK + passing := byName["passing"] + assert.Equal(t, int(codes.Ok), passing.StatusCode) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + spans := runSubprocess(t, tc.subtest) + if tc.check != nil { + tc.check(t, spans) + } else { + require.Len(t, spans, 1) + assert.Equal(t, int(codes.Error), spans[0].StatusCode) + assert.Equal(t, tc.wantDesc, spans[0].StatusDesc) + } + }) + } +} diff --git a/oteltestctx/log.go b/oteltestctx/log.go new file mode 100644 index 0000000..e768f39 --- /dev/null +++ b/oteltestctx/log.go @@ -0,0 +1 @@ +package oteltestctx diff --git a/oteltestctx/otel.go b/oteltestctx/otel.go new file mode 100644 index 0000000..050940a --- /dev/null +++ b/oteltestctx/otel.go @@ -0,0 +1,48 @@ +package oteltestctx + +import ( + "context" + "runtime/debug" + "strings" + "testing" + + otel "github.com/dagger/otel-go" +) + +const instrumentationLibrary = "dagger.io/testctx" + +const instrumentationVersion = "v0.1.0" + +var propagatedCtx = context.Background() + +// testPackage is the import path of the package under test, detected from +// the test binary's build info (e.g. "example.com/project/pkg"). +// It is auto-detected at package init time so that it is available even +// without calling [Main]. +var testPackage = detectTestPackage() + +// Main is a helper function that initializes OTel and runs the tests +// before exiting. Use it in your TestMain function. +// +// Main covers initializing the OTel trace and logger providers, pointing +// to standard OTEL_* env vars. +// +// It also initializes a context that will be used to propagate trace +// context to subtests. +func Main(m *testing.M) int { + propagatedCtx = otel.InitEmbedded(context.Background(), nil) + exitCode := m.Run() + otel.Close() + return exitCode +} + +// detectTestPackage returns the import path of the package under test by +// reading the test binary's build info. Go test binaries have a Path like +// "example.com/project/pkg.test"; we strip the ".test" suffix. +func detectTestPackage() string { + bi, ok := debug.ReadBuildInfo() + if !ok { + return "" + } + return strings.TrimSuffix(bi.Path, ".test") +} diff --git a/oteltestctx/otel_test.go b/oteltestctx/otel_test.go new file mode 100644 index 0000000..8c77927 --- /dev/null +++ b/oteltestctx/otel_test.go @@ -0,0 +1,260 @@ +package oteltestctx_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/dagger/otel-go/oteltestctx" + "github.com/dagger/testctx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +func TestMain(m *testing.M) { + os.Exit(oteltestctx.Main(m)) +} + +func TestOTel(t *testing.T) { + testctx.New(t, + testctx.WithParallel(), + oteltestctx.WithTracing[*testing.T](), + ).RunTests(OTelSuite{}) +} + +type OTelSuite struct{} + +func (OTelSuite) TestParallelAttribution(ctx context.Context, t *testctx.T) { + if testing.Short() { + t.Skip("skipping in short mode") + } + + t.Run("test", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("child", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("grandchild", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + }) + }) + + t.Run("test 2", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("child", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("grandchild", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + }) + }) + + t.Run("test 3", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("child", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("grandchild", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + }) + }) + + t.Run("test 4", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("child", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + + t.Run("grandchild", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + }) + }) +} + +func (OTelSuite) TestAutoSuiteName(ctx context.Context, t *testctx.T) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t.Unwrap(), oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + })) + + tt.Run("check-suite-name", func(ctx context.Context, t *testctx.T) {}) + + spans := spanRecorder.Ended() + require.Len(t, spans, 1) + assert.Contains(t, spans[0].Attributes(), + attribute.String("test.suite.name", "github.com/dagger/otel-go/oteltestctx")) +} + +func (OTelSuite) TestAttributes(ctx context.Context, t *testctx.T) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t.Unwrap(), oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + Attributes: []attribute.KeyValue{ + attribute.String("test.suite", "otel_test"), + }, + })) + + tt.Run("passing-test", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + + // Verify spans were recorded correctly + spans := spanRecorder.Ended() + require.Len(t, spans, 1) + + // Check passing test span + passSpan := spans[0] + assert.Equal(t, "passing-test", passSpan.Name()) + assert.Equal(t, codes.Ok, passSpan.Status().Code) + assert.Contains(t, passSpan.Attributes(), attribute.String("test.suite", "otel_test")) +} + +func (OTelSuite) TestStartOptions(ctx context.Context, t *testctx.T) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t.Unwrap(), oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + StartOptions: func(w *testctx.W[*testing.T]) []trace.SpanStartOption { + return []trace.SpanStartOption{ + trace.WithAttributes(attribute.String("test.suite", "otel_test")), + } + }, + })) + + tt.Run("passing-test", func(ctx context.Context, t *testctx.T) { + time.Sleep(time.Second) + }) + + // Verify spans were recorded correctly + spans := spanRecorder.Ended() + require.Len(t, spans, 1) + + // Check passing test span + passSpan := spans[0] + assert.Equal(t, "passing-test", passSpan.Name()) + assert.Equal(t, codes.Ok, passSpan.Status().Code) + assert.Contains(t, passSpan.Attributes(), attribute.String("test.suite", "otel_test")) +} + +func BenchmarkWithTracing(b *testing.B) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + bb := testctx.New(b, oteltestctx.WithTracing[*testing.B](oteltestctx.TraceConfig[*testing.B]{ + TracerProvider: tracerProvider, + })) + + bb.Run("traced-benchmark", func(ctx context.Context, b *testctx.B) { + bench := b.Unwrap() + for i := 0; i < bench.N; i++ { + time.Sleep(1 * time.Microsecond) + } + }) + + b.Logf("b.N: %d", b.N) + + // Verify benchmark span was recorded + spans := spanRecorder.Ended() + for _, span := range spans { + // dump all span data + b.Logf("span: %+v", span) + } + require.Len(b, spans, b.N) + + benchSpan := spans[0] + assert.Equal(b, "traced-benchmark", benchSpan.Name()) + assert.Equal(b, codes.Ok, benchSpan.Status().Code) +} + +func (OTelSuite) TestTracingNesting(ctx context.Context, t *testctx.T) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t.Unwrap(), oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + })) + + tt.Run("parent", func(ctx context.Context, t *testctx.T) { + time.Sleep(10 * time.Millisecond) + + t.Run("child", func(ctx context.Context, t *testctx.T) { + time.Sleep(10 * time.Millisecond) + + t.Run("grandchild", func(ctx context.Context, t *testctx.T) { + time.Sleep(10 * time.Millisecond) + }) + }) + }) + + spans := spanRecorder.Ended() + require.Len(t, spans, 3) + + // Spans should end in reverse order (grandchild, child, parent) + grandchild := spans[0] + child := spans[1] + parent := spans[2] + + // Verify names + assert.Equal(t, "grandchild", grandchild.Name()) + assert.Equal(t, "child", child.Name()) + assert.Equal(t, "parent", parent.Name()) + + // Verify span nesting + assert.Equal(t, child.SpanContext().SpanID(), grandchild.Parent().SpanID()) + assert.Equal(t, parent.SpanContext().SpanID(), child.Parent().SpanID()) + + // Verify timing - each span should end after its children + assert.True(t, grandchild.EndTime().Before(child.EndTime())) + assert.True(t, child.EndTime().Before(parent.EndTime())) +} + +func (OTelSuite) TestInterrupted(ctx context.Context, t *testctx.T) { + spanRecorder := tracetest.NewSpanRecorder() + tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + + tt := testctx.New(t.Unwrap(), + testctx.WithTimeout[*testing.T](100*time.Millisecond), // Short timeout to force interruption + oteltestctx.WithTracing(oteltestctx.TraceConfig[*testing.T]{ + TracerProvider: tracerProvider, + }), + ) + + // Run a test that will time out + tt.Run("timing-out-test", func(ctx context.Context, t *testctx.T) { + select { + case <-ctx.Done(): + // Test should be interrupted by timeout + return + case <-time.After(1 * time.Second): + t.Fatal("test should have timed out") + } + }) + + // Verify spans were recorded correctly + spans := spanRecorder.Ended() + require.Len(t, spans, 1) + + // Check the interrupted test span + timeoutSpan := spans[0] + assert.Equal(t, "timing-out-test", timeoutSpan.Name()) + assert.Equal(t, codes.Error, timeoutSpan.Status().Code) + assert.Equal(t, "test interrupted: context deadline exceeded", timeoutSpan.Status().Description) +} diff --git a/oteltestctx/trace.go b/oteltestctx/trace.go new file mode 100644 index 0000000..0aa16e7 --- /dev/null +++ b/oteltestctx/trace.go @@ -0,0 +1,291 @@ +package oteltestctx + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "os" + "strings" + "sync" + "time" + + "github.com/dagger/testctx" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/propagation" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" +) + +// TraceConfig holds configuration for the OpenTelemetry tracing middleware +type TraceConfig[T testctx.Runner[T]] struct { + // TracerProvider to use for creating spans. If nil, the global provider will be used. + TracerProvider trace.TracerProvider + // Attributes to add to all test spans + Attributes []attribute.KeyValue + // StartOptions allows customizing the span start options for each test/benchmark + StartOptions func(*testctx.W[T]) []trace.SpanStartOption +} + +// testSpanKey is the key used to store the test span in the context +type testSpanKey struct{} + +// WithTracing creates middleware that adds OpenTelemetry tracing around each test/benchmark +func WithTracing[T testctx.Runner[T]](cfg ...TraceConfig[T]) testctx.Middleware[T] { + var c TraceConfig[T] + customProvider := false + if len(cfg) > 0 { + c = cfg[0] + customProvider = c.TracerProvider != nil + } + if c.TracerProvider == nil { + c.TracerProvider = otel.GetTracerProvider() + } + + tracer := c.TracerProvider.Tracer( + instrumentationLibrary, + trace.WithInstrumentationVersion(instrumentationVersion), + ) + + // Check once whether an external coordinator is available via + // OTEL_TEST_SOCKET. When set, span contexts are retrieved from the + // external process (e.g., otelgotest) instead of creating local spans. + // Skip this when a custom TracerProvider was explicitly provided, + // since the caller wants local spans (e.g., unit tests with a + // span recorder). + var spanSocketAddr string + if !customProvider { + spanSocketAddr = os.Getenv("OTEL_TEST_SOCKET") + } + + return func(next testctx.RunFunc[T]) testctx.RunFunc[T] { + return func(ctx context.Context, w *testctx.W[T]) { + // If an external coordinator owns the spans, adopt its + // span context so in-process operations become children. + // The socket protocol uses package-qualified test names + // (e.g., "example.com/pkg/TestFoo/sub") so that tests + // across packages sharing a single socket don't collide. + if spanSocketAddr != "" { + if sc, err := requestSpanContext(spanSocketAddr, testPackage+"/"+w.Name()); err == nil && sc.IsValid() { + ctx = trace.ContextWithRemoteSpanContext(ctx, sc) + next(ctx, w) + return + } + } + + // Inherit from any trace context that Main picked up + if !trace.SpanContextFromContext(ctx).IsValid() { + ctx = trace.ContextWithSpanContext(ctx, trace.SpanContextFromContext(propagatedCtx)) + } + + // Start a new span for this test/benchmark + attrs := []attribute.KeyValue{ + semconv.TestCaseName(w.Name()), + } + if testPackage != "" { + attrs = append(attrs, semconv.TestSuiteName(testPackage)) + } + opts := []trace.SpanStartOption{ + trace.WithAttributes(attrs...), + trace.WithAttributes(c.Attributes...), + } + + // Link to the parent test span so that tools can attribute the subtest + // runtime to the parent test when tests are run in parallel + if val, ok := ctx.Value(testSpanKey{}).(trace.Span); ok { + opts = append(opts, trace.WithLinks(trace.Link{ + SpanContext: val.SpanContext(), + })) + } + + if c.StartOptions != nil { + opts = append(opts, c.StartOptions(w)...) + } + + spanName := w.BaseName() + + // Accumulate Error/Fatal messages so the span status carries the + // actual failure reason instead of a generic "test failed". + errorsAcc := &errorAccumulator{} + + ctx, span := tracer.Start(ctx, spanName, opts...) + + // Snapshot the sync-phase end time and context error in a + // defer. This fires when the test function returns — before + // Go waits for parallel subtests and before WithTimeout's + // defer cancel() unwinds. Capturing ctx.Err() here avoids + // confusing WithTimeout's cancel with a real interruption. + var syncEnd time.Time + var ctxErr error + defer func() { + syncEnd = time.Now() + ctxErr = ctx.Err() + }() + + // Use Cleanup to set the final status after all subtests + // (including parallel ones) complete. End the span at the + // sync-phase timestamp so its duration reflects the test's + // own work, not time spent waiting for parallel children. + w.Cleanup(func() { + var testStatus attribute.KeyValue + if ctxErr != nil { + // Test was interrupted (timeout or cancellation) + span.SetStatus(codes.Error, "test interrupted: "+ctxErr.Error()) + if errors.Is(ctxErr, context.DeadlineExceeded) { + testStatus = semconv.TestSuiteRunStatusTimedOut + } else { + testStatus = semconv.TestSuiteRunStatusAborted + } + } else if w.Failed() { + desc := errorsAcc.String() + if desc == "" { + desc = "test failed" + } + span.SetStatus(codes.Error, desc) + testStatus = semconv.TestSuiteRunStatusFailure + } else if w.Skipped() { + span.SetStatus(codes.Ok, "test skipped") + testStatus = semconv.TestSuiteRunStatusSkipped + } else { + span.SetStatus(codes.Ok, "test passed") + testStatus = semconv.TestCaseResultStatusPass + } + span.SetAttributes(testStatus) + span.End(trace.WithTimestamp(syncEnd)) + }) + + // Store the span in the context so that it can be linked to in subtests + ctx = context.WithValue(ctx, testSpanKey{}, span) + + next(ctx, w.WithLogger(errorsAcc)) + } + } +} + +// errorAccumulator is a Logger that captures Error/Errorf messages so they +// can be attached to a span status when the test fails. +type errorAccumulator struct { + mu sync.Mutex + messages []string +} + +var _ testctx.Logger = (*errorAccumulator)(nil) + +func (a *errorAccumulator) Log(args ...any) {} +func (a *errorAccumulator) Logf(format string, args ...any) {} + +func (a *errorAccumulator) Error(args ...any) { + a.mu.Lock() + defer a.mu.Unlock() + a.messages = append(a.messages, cleanErrorMessage(fmt.Sprint(args...))) +} + +func (a *errorAccumulator) Errorf(format string, args ...any) { + a.mu.Lock() + defer a.mu.Unlock() + a.messages = append(a.messages, cleanErrorMessage(fmt.Sprintf(format, args...))) +} + +// String returns all accumulated error messages joined by newlines. +func (a *errorAccumulator) String() string { + a.mu.Lock() + defer a.mu.Unlock() + return strings.Join(a.messages, "\n") +} + +// cleanErrorMessage extracts the meaningful error content from verbose test +// failure messages like those produced by testify's assert/require packages. +// It strips the "Error Trace:" and "Test:" sections, keeping only "Error:" +// and "Messages:" content. If the message doesn't match the expected format, +// it is returned unchanged (with leading/trailing whitespace trimmed). +func cleanErrorMessage(msg string) string { + // Quick check: does this look like a testify-formatted message? + if !strings.Contains(msg, "\tError:") { + return strings.TrimSpace(msg) + } + + lines := strings.Split(msg, "\n") + var result []string + inWanted := false + found := false + + for _, line := range lines { + // Section headers look like: \t:\t + // They start with \t followed by a non-whitespace character. + if len(line) > 1 && line[0] == '\t' && line[1] != ' ' && line[1] != '\t' { + inWanted = false + rest := line[1:] + colonIdx := strings.Index(rest, ":") + if colonIdx < 0 { + continue + } + name := rest[:colonIdx] + after := strings.TrimLeft(rest[colonIdx+1:], " ") + if len(after) == 0 || after[0] != '\t' { + continue + } + if name == "Error" || name == "Messages" { + inWanted = true + found = true + if v := strings.TrimSpace(after[1:]); v != "" { + result = append(result, v) + } + } + continue + } + + // Continuation lines look like: \t\t + if inWanted && len(line) > 0 && line[0] == '\t' { + rest := strings.TrimLeft(line[1:], " ") + if len(rest) > 0 && rest[0] == '\t' { + if v := strings.TrimSpace(rest[1:]); v != "" { + result = append(result, v) + } + } + } + } + + if found && len(result) > 0 { + return strings.Join(result, "\n") + } + return strings.TrimSpace(msg) +} + +// requestSpanContext connects to the otelgotest span context socket, +// sends the test name, and returns the span context from the response. +func requestSpanContext(addr, testName string) (trace.SpanContext, error) { + conn, err := net.DialTimeout("unix", addr, 5*time.Second) + if err != nil { + return trace.SpanContext{}, err + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + fmt.Fprintln(conn, testName) + + scanner := bufio.NewScanner(conn) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return trace.SpanContext{}, err + } + return trace.SpanContext{}, fmt.Errorf("no response from span context server") + } + + return parseTraceparent(scanner.Text()) +} + +// parseTraceparent parses a W3C traceparent string into a SpanContext. +func parseTraceparent(tp string) (trace.SpanContext, error) { + prop := propagation.TraceContext{} + ctx := prop.Extract(context.Background(), propagation.MapCarrier{ + "traceparent": tp, + }) + sc := trace.SpanContextFromContext(ctx) + if !sc.IsValid() { + return trace.SpanContext{}, fmt.Errorf("invalid traceparent: %s", tp) + } + return sc, nil +}