Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f57db38
feat: add gotest, gotestmain, and otelgotest for test reporting
vito Apr 7, 2026
16e450d
feat: add junit and oteljunit for JUnit XML to OTel conversion
vito Apr 7, 2026
ec9544e
test(junit): use go-junit-report generated fixture
vito Apr 7, 2026
40670fa
chore(junit): generate fixture from sample.jsonl via go-junit-report
vito Apr 7, 2026
2fa1d72
chore(gotest): generate sample.jsonl from in-tree test fixture
vito Apr 7, 2026
d7b409d
fix(junit): use raw span names and respect suite timestamp
vito Apr 7, 2026
ebfe843
feat(gotest): emit parent spans for each package
vito Apr 7, 2026
c4dc19f
feat(gotest): cross-process span context propagation via socket
vito Apr 7, 2026
6774e40
feat: add oteltestctx package (moved from testctx/oteltest)
vito Apr 7, 2026
6221c10
fix(gotest): clean testify errors in span status
vito Apr 7, 2026
00ba1a3
test(gotest): add error message cleaning integration tests
vito Apr 7, 2026
be85da2
fix(gotest): handle indented testify output from testing.decorate
vito Apr 7, 2026
cea1d5d
feat(otelgotest): rewrite as go test wrapper
vito Apr 7, 2026
d84ae07
test(gotest): add failing test for skipped package spans
vito Apr 7, 2026
413717e
fix(gotest): end spans for skipped packages
vito Apr 7, 2026
cf79fd2
fix: remove duplicate log sources causing log explosion
vito Apr 7, 2026
4fd036c
feat(gotest): add WithVerbose option to control output
vito Apr 7, 2026
961e254
fix(gotest): suppress bare PASS line in non-verbose mode
vito Apr 7, 2026
80db499
fix(otelgotest): support leading -C
vito Apr 8, 2026
0fcfffb
fix(gotest): handle slashy subtest names
vito Apr 9, 2026
a52f62c
fix(otelgotest): respect -json output
vito Apr 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions cmd/otelgotest/main.go
Original file line number Diff line number Diff line change
@@ -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") != ""
}
80 changes: 80 additions & 0 deletions cmd/otelgotest/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading