From f57db3851cd896d606b7bf367a744e2727248d64 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 6 Apr 2026 21:32:16 -0400 Subject: [PATCH 01/21] feat: add gotest, gotestmain, and otelgotest for test reporting Adds three packages for converting Go test results to OTel spans: - gotest: streaming go test -json to OTel span converter - gotestmain: TestMain helper for in-process instrumentation - cmd/otelgotest: go test -exec wrapper for zero-config use Each test becomes a span with subtest nesting, pass/fail/skip status, log output as span events, and optional OTel log record routing via SpanStdio. When no OTLP endpoint is configured, tests run with no overhead. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 150 ++++++++++++++++++++++++ go.mod | 4 + go.sum | 9 ++ gotest/gotest.go | 215 ++++++++++++++++++++++++++++++++++ gotest/gotest_test.go | 170 +++++++++++++++++++++++++++ gotest/testdata/sample.jsonl | 55 +++++++++ gotestmain/gotestmain.go | 139 ++++++++++++++++++++++ gotestmain/gotestmain_test.go | 53 +++++++++ 8 files changed, 795 insertions(+) create mode 100644 cmd/otelgotest/main.go create mode 100644 gotest/gotest.go create mode 100644 gotest/gotest_test.go create mode 100644 gotest/testdata/sample.jsonl create mode 100644 gotestmain/gotestmain.go create mode 100644 gotestmain/gotestmain_test.go diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go new file mode 100644 index 0000000..51edc7c --- /dev/null +++ b/cmd/otelgotest/main.go @@ -0,0 +1,150 @@ +// otelgotest is a go test -exec wrapper that emits OTel spans for each +// test. Usage: +// +// go test -exec otelgotest ./... +// +// When called by go test -exec, the first argument is the compiled test +// binary followed by -test.* flags. The wrapper runs the binary with +// -test.v=test2json, pipes stdout through go tool test2json, and feeds +// the JSON stream to gotest.Run for OTel export. +// +// The original human-readable output is reconstructed and printed to +// stdout so go test can process it normally (e.g. with -json or -v). +// +// If no OTLP endpoint is configured, the test binary is executed +// directly with no overhead. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + otel "github.com/dagger/otel-go" + "github.com/dagger/otel-go/gotest" + otelgo "go.opentelemetry.io/otel" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "usage: otelgotest [flags...]\n") + fmt.Fprintf(os.Stderr, " meant to be used with: go test -exec otelgotest\n") + os.Exit(1) + } + + os.Exit(run()) +} + +func run() int { + binary := os.Args[1] + args := os.Args[2:] + + // If no OTLP endpoint, just exec the binary directly. + if !hasOTLPEndpoint() { + return execDirect(binary, args) + } + + ctx := context.Background() + ctx = otel.InitEmbedded(ctx, nil) + defer otel.Close() + + tp := otelgo.GetTracerProvider() + + // Replace -test.v with -test.v=test2json for structured output. + testArgs := forceTest2JSON(args) + + // Detect package name from the binary path (e.g. "foo.test" -> "foo"). + pkg := detectPackage(binary) + + // Run the test binary, capturing stdout. + testCmd := exec.Command(binary, testArgs...) + testCmd.Stderr = os.Stderr + testCmd.Stdin = os.Stdin + + testOut, err := testCmd.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: stdout pipe: %v\n", err) + return execDirect(binary, args) + } + + // Pipe through go tool test2json to get JSON events. + test2jsonArgs := []string{"tool", "test2json"} + if pkg != "" { + test2jsonArgs = append(test2jsonArgs, "-p", pkg) + } + test2json := exec.Command("go", test2jsonArgs...) + test2json.Stdin = testOut + test2json.Stderr = os.Stderr + + jsonOut, err := test2json.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: test2json pipe: %v\n", err) + return execDirect(binary, args) + } + + if err := test2json.Start(); err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: test2json start: %v\n", err) + return execDirect(binary, args) + } + + if err := testCmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: test binary start: %v\n", err) + test2json.Process.Kill() + return execDirect(binary, args) + } + + // Process JSON events, writing human-readable output to stdout. + opts := []gotest.Option{gotest.WithOutput(os.Stdout)} + if lp := otel.LoggerProvider(ctx); lp != nil { + opts = append(opts, gotest.WithLoggerProvider(lp)) + } + gotest.Run(ctx, jsonOut, tp, opts...) + + // Wait for both processes. + testCmd.Wait() + test2json.Wait() + + if testCmd.ProcessState != nil { + return testCmd.ProcessState.ExitCode() + } + return 1 +} + +// execDirect runs the test binary with no instrumentation. +func execDirect(binary string, args []string) int { + cmd := exec.Command(binary, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + cmd.Run() + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() + } + return 1 +} + +// forceTest2JSON replaces any -test.v flag with -test.v=test2json +// and ensures the flag is present. +func forceTest2JSON(args []string) []string { + out := []string{"-test.v=test2json"} + for _, arg := range args { + if !strings.HasPrefix(arg, "-test.v") { + out = append(out, arg) + } + } + return out +} + +// detectPackage extracts a package name from the test binary path. +func detectPackage(binary string) string { + base := filepath.Base(binary) + return strings.TrimSuffix(base, ".test") +} + +func hasOTLPEndpoint() bool { + return os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" || + os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" +} diff --git a/go.mod b/go.mod index eac028f..875e737 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dagger/otel-go go 1.25.0 require ( + 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 +25,12 @@ 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/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 +39,5 @@ 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 ) diff --git a/go.sum b/go.sum index 4e89978..e2744ac 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,14 @@ 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/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/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 +81,8 @@ 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.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..c83e206 --- /dev/null +++ b/gotest/gotest.go @@ -0,0 +1,215 @@ +// Package gotest reads a go test -json stream and emits OTel spans +// for each test, with proper parent/child nesting for subtests. +package gotest + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "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 + output strings.Builder + streams *otel.SpanStreams +} + +// Option configures the behavior of Run. +type Option func(*runConfig) + +type runConfig struct { + output io.Writer + loggerProvider *sdklog.LoggerProvider +} + +// 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. +func WithOutput(w io.Writer) Option { + return func(c *runConfig) { c.output = w } +} + +// 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 } +} + +// 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/TestName" or "package/TestName/sub" + spans := map[string]*testSpan{} + + scanner := bufio.NewScanner(r) + for scanner.Scan() { + var ev TestEvent + if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { + return fmt.Errorf("decoding test event: %w", err) + } + + // Pass through the human-readable output. + if cfg.output != nil && ev.Output != "" { + io.WriteString(cfg.output, ev.Output) + } + + // Skip package-level events (no test name). + if ev.Test == "" { + continue + } + + key := ev.Package + "/" + ev.Test + + switch ev.Action { + case "run": + parentCtx := ctx + // For subtests, find the parent span. + if idx := strings.LastIndex(ev.Test, "/"); idx != -1 { + parentKey := ev.Package + "/" + ev.Test[:idx] + if ps, ok := spans[parentKey]; ok { + parentCtx = ps.ctx + } + } + + // Span name is the base name (leaf). + spanName := ev.Test + if idx := strings.LastIndex(ev.Test, "/"); idx != -1 { + spanName = ev.Test[idx+1:] + } + + 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, + } + if cfg.loggerProvider != nil { + spanCtx = otel.WithLoggerProvider(spanCtx, cfg.loggerProvider) + streams := otel.SpanStdio(spanCtx, instrumentationLibrary) + ts.streams = &streams + } + spans[key] = ts + + 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) + ts.span.AddEvent(trimmed, trace.WithTimestamp(ev.Time)) + + // Route to span logs if configured. + if ts.streams != nil { + io.WriteString(ts.streams.Stdout, ev.Output) + } + } + + case "pass": + if ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + 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 ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + 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 ts, ok := spans[key]; ok { + if ts.streams != nil { + ts.streams.Close() + } + 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 the file:line prefix that Go's testing package adds. +func extractErrorOutput(output string) string { + 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") +} diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go new file mode 100644 index 0000000..471e65d --- /dev/null +++ b/gotest/gotest_test.go @@ -0,0 +1,170 @@ +package gotest_test + +import ( + "os" + "slices" + "strings" + "testing" + + "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 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()) +} + +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 TestOutputCaptured(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestPass") + require.NotNil(t, span) + + events := span.Events() + found := slices.ContainsFunc(events, func(e sdktrace.Event) bool { + return strings.Contains(e.Name, "this test passes") + }) + assert.True(t, found, "expected output event containing 'this test passes', got events: %v", events) +} + +func TestSpanCount(t *testing.T) { + spans := runFixture(t) + + // TestPass, TestFail, TestSkip, + // TestSub, TestSub/level1, TestSub/level1/level2, + // TestParallel, TestParallel/a, TestParallel/b + // = 9 total + assert.Len(t, spans, 9) +} diff --git a/gotest/testdata/sample.jsonl b/gotest/testdata/sample.jsonl new file mode 100644 index 0000000..b067d2b --- /dev/null +++ b/gotest/testdata/sample.jsonl @@ -0,0 +1,55 @@ +{"Time":"2026-04-06T16:23:48.930440945-04:00","Action":"start","Package":"github.com/dagger/otel-test-reporter/testdata/sample"} +{"Time":"2026-04-06T16:23:48.931957627-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass"} +{"Time":"2026-04-06T16:23:48.931988555-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":"=== RUN TestPass\n"} +{"Time":"2026-04-06T16:23:48.942099914-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":" sample_test.go:10: this test passes\n"} +{"Time":"2026-04-06T16:23:48.942116005-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":"--- PASS: TestPass (0.01s)\n"} +{"Time":"2026-04-06T16:23:48.942118319-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Elapsed":0.01} +{"Time":"2026-04-06T16:23:48.942136223-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail"} +{"Time":"2026-04-06T16:23:48.942138687-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":"=== RUN TestFail\n"} +{"Time":"2026-04-06T16:23:48.952292958-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":" sample_test.go:15: something went wrong\n"} +{"Time":"2026-04-06T16:23:48.952316282-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":"--- FAIL: TestFail (0.01s)\n"} +{"Time":"2026-04-06T16:23:48.952321592-04:00","Action":"fail","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Elapsed":0.01} +{"Time":"2026-04-06T16:23:48.952327463-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip"} +{"Time":"2026-04-06T16:23:48.952330999-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":"=== RUN TestSkip\n"} +{"Time":"2026-04-06T16:23:48.952334005-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":" sample_test.go:19: not implemented yet\n"} +{"Time":"2026-04-06T16:23:48.952338173-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":"--- SKIP: TestSkip (0.00s)\n"} +{"Time":"2026-04-06T16:23:48.952344024-04:00","Action":"skip","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Elapsed":0} +{"Time":"2026-04-06T16:23:48.952348412-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub"} +{"Time":"2026-04-06T16:23:48.952352109-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Output":"=== RUN TestSub\n"} +{"Time":"2026-04-06T16:23:48.952356638-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1"} +{"Time":"2026-04-06T16:23:48.952360545-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":"=== RUN TestSub/level1\n"} +{"Time":"2026-04-06T16:23:48.952364893-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":" sample_test.go:24: in level1\n"} +{"Time":"2026-04-06T16:23:48.952371065-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2"} +{"Time":"2026-04-06T16:23:48.952374892-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":"=== RUN TestSub/level1/level2\n"} +{"Time":"2026-04-06T16:23:48.957465027-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":" sample_test.go:27: in level2\n"} +{"Time":"2026-04-06T16:23:48.957485405-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":"--- PASS: TestSub/level1/level2 (0.01s)\n"} +{"Time":"2026-04-06T16:23:48.957489773-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Elapsed":0.01} +{"Time":"2026-04-06T16:23:48.9574934-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":"--- PASS: TestSub/level1 (0.01s)\n"} +{"Time":"2026-04-06T16:23:48.957495174-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Elapsed":0.01} +{"Time":"2026-04-06T16:23:48.957497157-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Output":"--- PASS: TestSub (0.01s)\n"} +{"Time":"2026-04-06T16:23:48.957498921-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Elapsed":0.01} +{"Time":"2026-04-06T16:23:48.957500544-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel"} +{"Time":"2026-04-06T16:23:48.957501977-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Output":"=== RUN TestParallel\n"} +{"Time":"2026-04-06T16:23:48.9575036-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T16:23:48.957504892-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== RUN TestParallel/a\n"} +{"Time":"2026-04-06T16:23:48.957508429-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== PAUSE TestParallel/a\n"} +{"Time":"2026-04-06T16:23:48.957509761-04:00","Action":"pause","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T16:23:48.957511424-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T16:23:48.957512707-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== RUN TestParallel/b\n"} +{"Time":"2026-04-06T16:23:48.95751416-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== PAUSE TestParallel/b\n"} +{"Time":"2026-04-06T16:23:48.957515412-04:00","Action":"pause","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T16:23:48.957517095-04:00","Action":"cont","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} +{"Time":"2026-04-06T16:23:48.957518588-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== CONT TestParallel/a\n"} +{"Time":"2026-04-06T16:23:48.957520101-04:00","Action":"cont","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} +{"Time":"2026-04-06T16:23:48.957521303-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== CONT TestParallel/b\n"} +{"Time":"2026-04-06T16:23:48.977646818-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":" sample_test.go:41: parallel b done\n"} +{"Time":"2026-04-06T16:23:48.977655865-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":" sample_test.go:36: parallel a done\n"} +{"Time":"2026-04-06T16:23:48.97765863-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"--- PASS: TestParallel/b (0.02s)\n"} +{"Time":"2026-04-06T16:23:48.977664682-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Elapsed":0.02} +{"Time":"2026-04-06T16:23:48.977666896-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"--- PASS: TestParallel/a (0.02s)\n"} +{"Time":"2026-04-06T16:23:48.977668579-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Elapsed":0.02} +{"Time":"2026-04-06T16:23:48.977677155-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Output":"--- PASS: TestParallel (0.00s)\n"} +{"Time":"2026-04-06T16:23:48.977678828-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Elapsed":0} +{"Time":"2026-04-06T16:23:48.977680381-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Output":"FAIL\n"} +{"Time":"2026-04-06T16:23:48.977972252-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Output":"FAIL\tgithub.com/dagger/otel-test-reporter/testdata/sample\t0.047s\n"} +{"Time":"2026-04-06T16:23:48.977991919-04:00","Action":"fail","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Elapsed":0.048} 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") + }) +} From 16e450d650181294d3c43832fa0d075b7d37dee0 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 6 Apr 2026 22:16:28 -0400 Subject: [PATCH 02/21] feat: add junit and oteljunit for JUnit XML to OTel conversion Adds a junit package that parses JUnit XML via go-junit and emits OTel spans mirroring the suite/test hierarchy. Each suite becomes a parent span with child spans per test case, carrying pass/fail/skip status, test output as span events, and optional log records via SpanStdio. The cmd/oteljunit CLI reads JUnit XML from files or stdin and exports the spans to the configured OTLP endpoint. Signed-off-by: Alex Suraci --- cmd/oteljunit/main.go | 63 ++++++++++++++++ go.mod | 1 + go.sum | 6 ++ junit/junit.go | 148 ++++++++++++++++++++++++++++++++++++++ junit/junit_test.go | 121 +++++++++++++++++++++++++++++++ junit/testdata/sample.xml | 20 ++++++ 6 files changed, 359 insertions(+) create mode 100644 cmd/oteljunit/main.go create mode 100644 junit/junit.go create mode 100644 junit/junit_test.go create mode 100644 junit/testdata/sample.xml 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 875e737..0276c05 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/dagger/otel-go go 1.25.0 require ( + 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 diff --git a/go.sum b/go.sum index e2744ac..4140807 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ 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/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= @@ -17,6 +18,8 @@ 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/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= @@ -25,6 +28,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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= @@ -84,5 +89,6 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j 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/junit/junit.go b/junit/junit.go new file mode 100644 index 0000000..cbbb1e0 --- /dev/null +++ b/junit/junit.go @@ -0,0 +1,148 @@ +// Package junit converts JUnit XML test suites into OTel spans. +package junit + +import ( + "context" + "io" + "strings" + "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 +} + +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" + } + + suiteCtx, suiteSpan := tracer.Start(ctx, suiteName, + trace.WithAttributes( + semconv.TestSuiteName(suiteName), + ), + ) + + for _, test := range suite.Tests { + emitTest(suiteCtx, tracer, cfg, suiteName, 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) + } + + suiteSpan.End() +} + +func emitTest(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suiteName string, test junitparser.Test) { + // Use the base name for the span, full name for the attribute. + spanName := test.Name + if idx := strings.LastIndex(test.Name, "/"); idx != -1 { + spanName = test.Name[idx+1:] + } + + now := time.Now() + startTime := now.Add(-test.Duration) + + spanCtx, span := tracer.Start(ctx, spanName, + 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() + } + + // Emit output as span events too. + if test.SystemOut != "" { + span.AddEvent(strings.TrimSpace(test.SystemOut)) + } + + 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(now)) +} diff --git a/junit/junit_test.go b/junit/junit_test.go new file mode 100644 index 0000000..34a8376 --- /dev/null +++ b/junit/junit_test.go @@ -0,0 +1,121 @@ +package junit_test + +import ( + "os" + "slices" + "strings" + "testing" + + "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.Status().Description, "something went wrong") + 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/example/pkg") + 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 TestOutputCaptured(t *testing.T) { + spans := runFixture(t) + + span := findSpan(spans, "TestPass") + require.NotNil(t, span) + + events := span.Events() + found := slices.ContainsFunc(events, func(e sdktrace.Event) bool { + return strings.Contains(e.Name, "this test passes") + }) + assert.True(t, found, "expected output event containing 'this test passes'") +} + +func TestSpanCount(t *testing.T) { + spans := runFixture(t) + + // 1 suite + 5 tests = 6 spans + assert.Len(t, spans, 6) +} diff --git a/junit/testdata/sample.xml b/junit/testdata/sample.xml new file mode 100644 index 0000000..b3f3622 --- /dev/null +++ b/junit/testdata/sample.xml @@ -0,0 +1,20 @@ + + + + + this test passes + + + sample_test.go:15: something went wrong + + + + + + in level1 + + + in level2 + + + From ec9544eb9fba856659b2ca110583431c3a57eb69 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 6 Apr 2026 22:24:55 -0400 Subject: [PATCH 03/21] test(junit): use go-junit-report generated fixture Replace the hand-crafted JUnit XML with output from go-junit-report run against the sample test suite. Adjust tests to match the real format: empty suite name (falls back to "suite"), generic failure messages, no per-test system-out, flat subtest names. Signed-off-by: Alex Suraci --- junit/junit_test.go | 27 ++++++------ junit/testdata/sample.xml | 89 +++++++++++++++++++++++++++++++-------- 2 files changed, 83 insertions(+), 33 deletions(-) diff --git a/junit/junit_test.go b/junit/junit_test.go index 34a8376..8d882af 100644 --- a/junit/junit_test.go +++ b/junit/junit_test.go @@ -2,8 +2,6 @@ package junit_test import ( "os" - "slices" - "strings" "testing" "github.com/dagger/otel-go/junit" @@ -70,7 +68,6 @@ func TestFailingTest(t *testing.T) { 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") assert.Contains(t, span.Attributes(), semconv.TestSuiteRunStatusFailure) } @@ -87,10 +84,11 @@ func TestSkippedTest(t *testing.T) { func TestSuiteSpan(t *testing.T) { spans := runFixture(t) - suite := findSpan(spans, "github.com/example/pkg") + // go-junit-report emits an empty suite name; we fall back to "suite". + suite := findSpan(spans, "suite") require.NotNil(t, suite, "expected suite span") - // Suite has failures, so it should be marked as error. + // 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) @@ -100,22 +98,21 @@ func TestSuiteSpan(t *testing.T) { assert.Equal(t, suite.SpanContext().SpanID(), pass.Parent().SpanID()) } -func TestOutputCaptured(t *testing.T) { +func TestSubtestSpanName(t *testing.T) { spans := runFixture(t) - span := findSpan(spans, "TestPass") - require.NotNil(t, span) + // JUnit flattens subtests as "TestSub/level1/level2". + // Span name should be the leaf: "level2". + span := findSpan(spans, "level2") + require.NotNil(t, span, "expected span with leaf name 'level2'") - events := span.Events() - found := slices.ContainsFunc(events, func(e sdktrace.Event) bool { - return strings.Contains(e.Name, "this test passes") - }) - assert.True(t, found, "expected output event containing 'this test passes'") + assert.Equal(t, "TestSub/level1/level2", + spanAttr(span, semconv.TestCaseNameKey).AsString()) } func TestSpanCount(t *testing.T) { spans := runFixture(t) - // 1 suite + 5 tests = 6 spans - assert.Len(t, spans, 6) + // 1 suite + 9 tests = 10 spans + assert.Len(t, spans, 10) } diff --git a/junit/testdata/sample.xml b/junit/testdata/sample.xml index b3f3622..44d2c1d 100644 --- a/junit/testdata/sample.xml +++ b/junit/testdata/sample.xml @@ -1,20 +1,73 @@ - - - - this test passes - - - sample_test.go:15: something went wrong - - - - - - in level1 - - - in level2 - - + + + + + + + + + + + + + + + + + From 40670faccf9b9760a120007ef2ab7c30c3800127 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 6 Apr 2026 22:27:43 -0400 Subject: [PATCH 04/21] chore(junit): generate fixture from sample.jsonl via go-junit-report Add go:generate directive that pipes gotest/testdata/sample.jsonl through go-junit-report to produce the JUnit XML fixture. Track go-junit-report as a tool dependency in go.mod. The JSON parser produces richer output than piping raw test output: suite names, classnames, per-test system-out, and failure bodies are all populated. Signed-off-by: Alex Suraci --- go.mod | 3 ++ go.sum | 3 ++ junit/junit.go | 2 + junit/junit_test.go | 3 +- junit/testdata/sample.xml | 88 ++++++++++----------------------------- 5 files changed, 30 insertions(+), 69 deletions(-) diff --git a/go.mod b/go.mod index 0276c05..6c310f8 100644 --- a/go.mod +++ b/go.mod @@ -31,6 +31,7 @@ require ( 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 @@ -42,3 +43,5 @@ require ( 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 4140807..3e2ca9c 100644 --- a/go.sum +++ b/go.sum @@ -12,6 +12,7 @@ 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= @@ -20,6 +21,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz 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= diff --git a/junit/junit.go b/junit/junit.go index cbbb1e0..c1327e3 100644 --- a/junit/junit.go +++ b/junit/junit.go @@ -1,6 +1,8 @@ // 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" diff --git a/junit/junit_test.go b/junit/junit_test.go index 8d882af..e8d90cf 100644 --- a/junit/junit_test.go +++ b/junit/junit_test.go @@ -84,8 +84,7 @@ func TestSkippedTest(t *testing.T) { func TestSuiteSpan(t *testing.T) { spans := runFixture(t) - // go-junit-report emits an empty suite name; we fall back to "suite". - suite := findSpan(spans, "suite") + suite := findSpan(spans, "github.com/dagger/otel-test-reporter/testdata/sample") require.NotNil(t, suite, "expected suite span") // Suite has failures so it should be marked as error. diff --git a/junit/testdata/sample.xml b/junit/testdata/sample.xml index 44d2c1d..5f5ca56 100644 --- a/junit/testdata/sample.xml +++ b/junit/testdata/sample.xml @@ -1,73 +1,27 @@ - - - - + + + - - + + + + + + + + + + + + + + + + + - - - - - - - From 2fa1d72f48bfc6eeb60c88c3d305a28d0089d3ba Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Mon, 6 Apr 2026 22:30:34 -0400 Subject: [PATCH 05/21] chore(gotest): generate sample.jsonl from in-tree test fixture Add the sample test source to gotest/testdata/sample/ and a go:generate directive that runs it with go test -json to produce sample.jsonl. The JUnit fixture chains from the same source: go generate ./gotest/ # sample_test.go -> sample.jsonl go generate ./junit/ # sample.jsonl -> sample.xml Signed-off-by: Alex Suraci --- gotest/gotest.go | 2 + gotest/testdata/sample.jsonl | 110 +++++++++++++------------- gotest/testdata/sample/go.mod | 3 + gotest/testdata/sample/sample_test.go | 43 ++++++++++ junit/junit_test.go | 2 +- junit/testdata/sample.xml | 24 +++--- 6 files changed, 116 insertions(+), 68 deletions(-) create mode 100644 gotest/testdata/sample/go.mod create mode 100644 gotest/testdata/sample/sample_test.go diff --git a/gotest/gotest.go b/gotest/gotest.go index c83e206..44b50fd 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -2,6 +2,8 @@ // 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" diff --git a/gotest/testdata/sample.jsonl b/gotest/testdata/sample.jsonl index b067d2b..5b457b2 100644 --- a/gotest/testdata/sample.jsonl +++ b/gotest/testdata/sample.jsonl @@ -1,55 +1,55 @@ -{"Time":"2026-04-06T16:23:48.930440945-04:00","Action":"start","Package":"github.com/dagger/otel-test-reporter/testdata/sample"} -{"Time":"2026-04-06T16:23:48.931957627-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass"} -{"Time":"2026-04-06T16:23:48.931988555-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":"=== RUN TestPass\n"} -{"Time":"2026-04-06T16:23:48.942099914-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":" sample_test.go:10: this test passes\n"} -{"Time":"2026-04-06T16:23:48.942116005-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Output":"--- PASS: TestPass (0.01s)\n"} -{"Time":"2026-04-06T16:23:48.942118319-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestPass","Elapsed":0.01} -{"Time":"2026-04-06T16:23:48.942136223-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail"} -{"Time":"2026-04-06T16:23:48.942138687-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":"=== RUN TestFail\n"} -{"Time":"2026-04-06T16:23:48.952292958-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":" sample_test.go:15: something went wrong\n"} -{"Time":"2026-04-06T16:23:48.952316282-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Output":"--- FAIL: TestFail (0.01s)\n"} -{"Time":"2026-04-06T16:23:48.952321592-04:00","Action":"fail","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestFail","Elapsed":0.01} -{"Time":"2026-04-06T16:23:48.952327463-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip"} -{"Time":"2026-04-06T16:23:48.952330999-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":"=== RUN TestSkip\n"} -{"Time":"2026-04-06T16:23:48.952334005-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":" sample_test.go:19: not implemented yet\n"} -{"Time":"2026-04-06T16:23:48.952338173-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Output":"--- SKIP: TestSkip (0.00s)\n"} -{"Time":"2026-04-06T16:23:48.952344024-04:00","Action":"skip","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSkip","Elapsed":0} -{"Time":"2026-04-06T16:23:48.952348412-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub"} -{"Time":"2026-04-06T16:23:48.952352109-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Output":"=== RUN TestSub\n"} -{"Time":"2026-04-06T16:23:48.952356638-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1"} -{"Time":"2026-04-06T16:23:48.952360545-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":"=== RUN TestSub/level1\n"} -{"Time":"2026-04-06T16:23:48.952364893-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":" sample_test.go:24: in level1\n"} -{"Time":"2026-04-06T16:23:48.952371065-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2"} -{"Time":"2026-04-06T16:23:48.952374892-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":"=== RUN TestSub/level1/level2\n"} -{"Time":"2026-04-06T16:23:48.957465027-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":" sample_test.go:27: in level2\n"} -{"Time":"2026-04-06T16:23:48.957485405-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Output":"--- PASS: TestSub/level1/level2 (0.01s)\n"} -{"Time":"2026-04-06T16:23:48.957489773-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1/level2","Elapsed":0.01} -{"Time":"2026-04-06T16:23:48.9574934-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Output":"--- PASS: TestSub/level1 (0.01s)\n"} -{"Time":"2026-04-06T16:23:48.957495174-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub/level1","Elapsed":0.01} -{"Time":"2026-04-06T16:23:48.957497157-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Output":"--- PASS: TestSub (0.01s)\n"} -{"Time":"2026-04-06T16:23:48.957498921-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestSub","Elapsed":0.01} -{"Time":"2026-04-06T16:23:48.957500544-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel"} -{"Time":"2026-04-06T16:23:48.957501977-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Output":"=== RUN TestParallel\n"} -{"Time":"2026-04-06T16:23:48.9575036-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} -{"Time":"2026-04-06T16:23:48.957504892-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== RUN TestParallel/a\n"} -{"Time":"2026-04-06T16:23:48.957508429-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== PAUSE TestParallel/a\n"} -{"Time":"2026-04-06T16:23:48.957509761-04:00","Action":"pause","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} -{"Time":"2026-04-06T16:23:48.957511424-04:00","Action":"run","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} -{"Time":"2026-04-06T16:23:48.957512707-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== RUN TestParallel/b\n"} -{"Time":"2026-04-06T16:23:48.95751416-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== PAUSE TestParallel/b\n"} -{"Time":"2026-04-06T16:23:48.957515412-04:00","Action":"pause","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} -{"Time":"2026-04-06T16:23:48.957517095-04:00","Action":"cont","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a"} -{"Time":"2026-04-06T16:23:48.957518588-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"=== CONT TestParallel/a\n"} -{"Time":"2026-04-06T16:23:48.957520101-04:00","Action":"cont","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b"} -{"Time":"2026-04-06T16:23:48.957521303-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"=== CONT TestParallel/b\n"} -{"Time":"2026-04-06T16:23:48.977646818-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":" sample_test.go:41: parallel b done\n"} -{"Time":"2026-04-06T16:23:48.977655865-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":" sample_test.go:36: parallel a done\n"} -{"Time":"2026-04-06T16:23:48.97765863-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Output":"--- PASS: TestParallel/b (0.02s)\n"} -{"Time":"2026-04-06T16:23:48.977664682-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/b","Elapsed":0.02} -{"Time":"2026-04-06T16:23:48.977666896-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Output":"--- PASS: TestParallel/a (0.02s)\n"} -{"Time":"2026-04-06T16:23:48.977668579-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel/a","Elapsed":0.02} -{"Time":"2026-04-06T16:23:48.977677155-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Output":"--- PASS: TestParallel (0.00s)\n"} -{"Time":"2026-04-06T16:23:48.977678828-04:00","Action":"pass","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Test":"TestParallel","Elapsed":0} -{"Time":"2026-04-06T16:23:48.977680381-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Output":"FAIL\n"} -{"Time":"2026-04-06T16:23:48.977972252-04:00","Action":"output","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Output":"FAIL\tgithub.com/dagger/otel-test-reporter/testdata/sample\t0.047s\n"} -{"Time":"2026-04-06T16:23:48.977991919-04:00","Action":"fail","Package":"github.com/dagger/otel-test-reporter/testdata/sample","Elapsed":0.048} +{"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/junit/junit_test.go b/junit/junit_test.go index e8d90cf..56fcfb5 100644 --- a/junit/junit_test.go +++ b/junit/junit_test.go @@ -84,7 +84,7 @@ func TestSkippedTest(t *testing.T) { func TestSuiteSpan(t *testing.T) { spans := runFixture(t) - suite := findSpan(spans, "github.com/dagger/otel-test-reporter/testdata/sample") + 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. diff --git a/junit/testdata/sample.xml b/junit/testdata/sample.xml index 5f5ca56..0e97ffe 100644 --- a/junit/testdata/sample.xml +++ b/junit/testdata/sample.xml @@ -1,27 +1,27 @@ - - + + - + - + - - + + - + - - - - + + + + From d7b409d3913f04bab0bfb8d10acc4b7741f0ea3f Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 10:47:53 -0400 Subject: [PATCH 06/21] fix(junit): use raw span names and respect suite timestamp Stop splitting test names on '/' for span names since JUnit can't express hierarchy anyway. Parse the timestamp attribute from the testsuite XML element to set proper start/end times on spans instead of calculating backwards from time.Now(). Signed-off-by: Alex Suraci --- junit/junit.go | 66 +++++++++++++++++++++++++++++++++------------ junit/junit_test.go | 28 ++++++++++++++++--- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/junit/junit.go b/junit/junit.go index c1327e3..c3ad161 100644 --- a/junit/junit.go +++ b/junit/junit.go @@ -53,6 +53,24 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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 == "" { @@ -62,14 +80,20 @@ func emitSuite(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suite j suiteName = "suite" } - suiteCtx, suiteSpan := tracer.Start(ctx, suiteName, - trace.WithAttributes( - semconv.TestSuiteName(suiteName), - ), - ) + 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, test) + emitTest(suiteCtx, tracer, cfg, suiteName, ts, test) } // Nested suites. @@ -86,20 +110,28 @@ func emitSuite(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suite j suiteSpan.SetAttributes(semconv.TestCaseResultStatusPass) } - suiteSpan.End() + 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, test junitparser.Test) { - // Use the base name for the span, full name for the attribute. - spanName := test.Name - if idx := strings.LastIndex(test.Name, "/"); idx != -1 { - spanName = test.Name[idx+1:] +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) } - now := time.Now() - startTime := now.Add(-test.Duration) - - spanCtx, span := tracer.Start(ctx, spanName, + spanCtx, span := tracer.Start(ctx, test.Name, trace.WithTimestamp(startTime), trace.WithAttributes( semconv.TestCaseName(test.Name), @@ -146,5 +178,5 @@ func emitTest(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suiteNam span.SetAttributes(semconv.TestSuiteRunStatusSkipped) } - span.End(trace.WithTimestamp(now)) + span.End(trace.WithTimestamp(endTime)) } diff --git a/junit/junit_test.go b/junit/junit_test.go index 56fcfb5..845d8cd 100644 --- a/junit/junit_test.go +++ b/junit/junit_test.go @@ -3,6 +3,7 @@ package junit_test import ( "os" "testing" + "time" "github.com/dagger/otel-go/junit" "github.com/stretchr/testify/assert" @@ -101,14 +102,35 @@ func TestSubtestSpanName(t *testing.T) { spans := runFixture(t) // JUnit flattens subtests as "TestSub/level1/level2". - // Span name should be the leaf: "level2". - span := findSpan(spans, "level2") - require.NotNil(t, span, "expected span with leaf name '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) From ebfe84308d552209fd46e35f8764bd673f76b72c Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 10:48:01 -0400 Subject: [PATCH 07/21] feat(gotest): emit parent spans for each package Handle package-level JSON events (start/pass/fail) to create a parent span per package instead of emitting all test spans at the top level. Top-level tests are parented under their package span, subtests remain nested under their parent test. Signed-off-by: Alex Suraci --- gotest/gotest.go | 51 +++++++++++++++++++++++++++++++++++++++++-- gotest/gotest_test.go | 22 +++++++++++++++++-- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index 44b50fd..23cfa50 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -47,6 +47,7 @@ type Option func(*runConfig) type runConfig struct { output io.Writer loggerProvider *sdklog.LoggerProvider + registry *SpanContextRegistry } // WithOutput passes through the human-readable test output (the Output @@ -63,6 +64,14 @@ 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 @@ -75,6 +84,9 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti // key: "package/TestName" or "package/TestName/sub" spans := map[string]*testSpan{} + // pkgSpans tracks a parent span per package. + pkgSpans := map[string]*testSpan{} + scanner := bufio.NewScanner(r) for scanner.Scan() { var ev TestEvent @@ -87,8 +99,35 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti io.WriteString(cfg.output, ev.Output) } - // Skip package-level events (no test name). + // 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) + } + 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) + } + } continue } @@ -96,8 +135,12 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti switch ev.Action { case "run": + // Default parent is the package span, or the top-level ctx. parentCtx := ctx - // For subtests, find the parent span. + if ps, ok := pkgSpans[ev.Package]; ok { + parentCtx = ps.ctx + } + // For subtests, find the parent test span. if idx := strings.LastIndex(ev.Test, "/"); idx != -1 { parentKey := ev.Package + "/" + ev.Test[:idx] if ps, ok := spans[parentKey]; ok { @@ -130,6 +173,10 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } spans[key] = ts + if cfg.registry != nil { + cfg.registry.Register(ev.Test, span.SpanContext()) + } + case "output": if ts, ok := spans[key]; ok { // Filter out the === RUN / --- PASS/FAIL/SKIP lines. diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go index 471e65d..4def894 100644 --- a/gotest/gotest_test.go +++ b/gotest/gotest_test.go @@ -125,6 +125,23 @@ func TestSubtestNesting(t *testing.T) { spanAttr(grandchild, semconv.TestCaseNameKey).AsString()) } +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) @@ -162,9 +179,10 @@ func TestOutputCaptured(t *testing.T) { func TestSpanCount(t *testing.T) { spans := runFixture(t) + // 1 package + // TestPass, TestFail, TestSkip, // TestSub, TestSub/level1, TestSub/level1/level2, // TestParallel, TestParallel/a, TestParallel/b - // = 9 total - assert.Len(t, spans, 9) + // = 10 total + assert.Len(t, spans, 10) } From c4dc19f8763f12c287cbcc9d50d351c9ece07680 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 10:48:23 -0400 Subject: [PATCH 08/21] feat(gotest): cross-process span context propagation via socket Add a Unix socket sideband so that test binaries using oteltestctx can adopt span contexts created by otelgotest. When otelgotest creates a test span from JSON events, it registers the span context in a SpanContextRegistry. A socket server responds to requests from the test binary with the traceparent for each test. This lets in-process instrumented operations (e.g. Dagger calls) descend from the externally created test span without any configuration flags. Also fixes otelgotest package detection to use the full import path by walking up to go.mod from the working directory. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 123 ++++++++++++- gotest/integration_test.go | 355 +++++++++++++++++++++++++++++++++++++ gotest/registry.go | 80 +++++++++ 3 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 gotest/integration_test.go create mode 100644 gotest/registry.go diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go index 51edc7c..b117823 100644 --- a/cmd/otelgotest/main.go +++ b/cmd/otelgotest/main.go @@ -16,16 +16,21 @@ package main import ( + "bufio" + "bytes" "context" "fmt" + "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() { @@ -59,8 +64,33 @@ func run() int { // Detect package name from the binary path (e.g. "foo.test" -> "foo"). pkg := detectPackage(binary) + // Set up a Unix socket for cross-process span context propagation. + // Test binaries using oteltest can connect to this socket to retrieve + // the span context of the externally created test span, so that + // in-process operations become children of it. + registry := gotest.NewSpanContextRegistry() + defer registry.Close() + + tmpDir, err := os.MkdirTemp("", "otelgotest") + if err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: tmpdir: %v\n", err) + return execDirect(binary, 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 execDirect(binary, args) + } + defer listener.Close() + + go serveSpanContexts(listener, registry) + // Run the test binary, capturing stdout. testCmd := exec.Command(binary, testArgs...) + testCmd.Env = append(os.Environ(), "OTEL_TEST_SOCKET="+socketPath) testCmd.Stderr = os.Stderr testCmd.Stdin = os.Stdin @@ -97,7 +127,10 @@ func run() int { } // Process JSON events, writing human-readable output to stdout. - opts := []gotest.Option{gotest.WithOutput(os.Stdout)} + opts := []gotest.Option{ + gotest.WithOutput(os.Stdout), + gotest.WithSpanContextRegistry(registry), + } if lp := otel.LoggerProvider(ctx); lp != nil { opts = append(opts, gotest.WithLoggerProvider(lp)) } @@ -138,12 +171,98 @@ func forceTest2JSON(args []string) []string { return out } -// detectPackage extracts a package name from the test binary path. +// detectPackage determines the full import path of the package under test. +// When invoked via go test -exec, the working directory is the package's +// source directory, so we walk up to find go.mod and combine the module +// path with the relative directory. func detectPackage(binary string) string { + cwd, err := os.Getwd() + if err != nil { + return fallbackPackage(binary) + } + + // Walk up from cwd to find go.mod. + dir := cwd + for { + modPath, err := parseModulePath(filepath.Join(dir, "go.mod")) + if err == nil { + rel, err := filepath.Rel(dir, cwd) + if err != nil || rel == "." { + return modPath + } + return modPath + "/" + filepath.ToSlash(rel) + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return fallbackPackage(binary) +} + +// fallbackPackage extracts a short package name from the test binary path. +func fallbackPackage(binary string) string { base := filepath.Base(binary) return strings.TrimSuffix(base, ".test") } +// parseModulePath reads the module path from a go.mod file. +func parseModulePath(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "module ") { + return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil + } + } + return "", fmt.Errorf("no module directive found in %s", path) +} + +// 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 test name (one line), and +// the server responds with the W3C traceparent (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 + } + testName := scanner.Text() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sc, ok := registry.WaitFor(ctx, testName) + 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()) +} + func hasOTLPEndpoint() bool { return os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" || os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" diff --git a/gotest/integration_test.go b/gotest/integration_test.go new file mode 100644 index 0000000..f1276da --- /dev/null +++ b/gotest/integration_test.go @@ -0,0 +1,355 @@ +package gotest_test + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "path/filepath" + "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/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +// 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. + remoteSC := requestSpanContext(t, socketPath, "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. + parentSC := requestSpanContext(t, socketPath, "TestParent") + subSC := requestSpanContext(t, socketPath, "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, "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) + + // 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: "example.com/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: "example.com/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: "example.com/pkg", Test: fullTestName}) + enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}) + pw.Close() + + require.NoError(t, <-done) + + // Find the spans. + spans := spanRecorder.Ended() + + var gotestSpan sdktrace.ReadOnlySpan + var downstreamSpan sdktrace.ReadOnlySpan + for _, s := range spans { + switch s.Name() { + case "inner": + gotestSpan = s + case "downstream-call": + downstreamSpan = s + } + } + require.NotNil(t, gotestSpan, "expected gotest span 'inner'") + 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") +} + +// --- 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 +} From 6774e40702c74dbcfa552cf45a85e79f2ecfccda Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 10:48:33 -0400 Subject: [PATCH 09/21] feat: add oteltestctx package (moved from testctx/oteltest) Move the oteltest middleware into this repo as oteltestctx so it can share types with gotest and evolve the socket protocol without a circular dependency. WithTracing checks OTEL_TEST_SOCKET and adopts the external span context when available, falling back to local span creation. The socket is skipped when a custom TracerProvider is provided (e.g. unit tests with span recorders). Subprocess tests clear the env var to avoid connecting to the parent's socket. Signed-off-by: Alex Suraci --- go.mod | 1 + go.sum | 2 + oteltestctx/clean_test.go | 75 ++++++++++ oteltestctx/failed_test.go | 220 ++++++++++++++++++++++++++++ oteltestctx/log.go | 62 ++++++++ oteltestctx/otel.go | 47 ++++++ oteltestctx/otel_test.go | 280 ++++++++++++++++++++++++++++++++++++ oteltestctx/trace.go | 288 +++++++++++++++++++++++++++++++++++++ 8 files changed, 975 insertions(+) create mode 100644 oteltestctx/clean_test.go create mode 100644 oteltestctx/failed_test.go create mode 100644 oteltestctx/log.go create mode 100644 oteltestctx/otel.go create mode 100644 oteltestctx/otel_test.go create mode 100644 oteltestctx/trace.go diff --git a/go.mod b/go.mod index 6c310f8..493d32c 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 diff --git a/go.sum b/go.sum index 3e2ca9c..865f43a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ 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= 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..a71f3f3 --- /dev/null +++ b/oteltestctx/log.go @@ -0,0 +1,62 @@ +package oteltestctx + +import ( + "context" + "fmt" + + otel "github.com/dagger/otel-go" + "github.com/dagger/testctx" + "go.opentelemetry.io/otel/log" + sdklog "go.opentelemetry.io/otel/sdk/log" +) + +// LogConfig holds configuration for the OpenTelemetry logging middleware +type LogConfig struct { + // LoggerProvider to use for logging. If nil, the global provider will be used. + LoggerProvider *sdklog.LoggerProvider + // Attributes to add to all test logs + Attributes []log.KeyValue +} + +// WithLogging creates middleware that adds OpenTelemetry logging to each test/benchmark +func WithLogging[T testctx.Runner[T]](cfg ...LogConfig) testctx.Middleware[T] { + var c LogConfig + if len(cfg) > 0 { + c = cfg[0] + } + if c.LoggerProvider == nil { + c.LoggerProvider = otel.LoggerProvider(propagatedCtx) + } + + return func(next testctx.RunFunc[T]) testctx.RunFunc[T] { + return func(ctx context.Context, w *testctx.W[T]) { + // Use the same logger provider as the main test + ctx = otel.WithLoggerProvider(ctx, c.LoggerProvider) + + // Send logs to the span + next(ctx, w.WithLogger(&spanLogger{ + streams: otel.SpanStdio(ctx, instrumentationLibrary, c.Attributes...), + })) + } + } +} + +type spanLogger struct { + streams otel.SpanStreams +} + +func (l *spanLogger) Log(args ...any) { + fmt.Fprintln(l.streams.Stdout, args...) +} + +func (l *spanLogger) Logf(format string, args ...any) { + fmt.Fprintf(l.streams.Stdout, format+"\n", args...) +} + +func (l *spanLogger) Error(args ...any) { + fmt.Fprintln(l.streams.Stderr, args...) +} + +func (l *spanLogger) Errorf(format string, args ...any) { + fmt.Fprintf(l.streams.Stderr, format+"\n", args...) +} diff --git a/oteltestctx/otel.go b/oteltestctx/otel.go new file mode 100644 index 0000000..552c36d --- /dev/null +++ b/oteltestctx/otel.go @@ -0,0 +1,47 @@ +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"). +var testPackage string + +// 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) + testPackage = detectTestPackage() + 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..4e27d71 --- /dev/null +++ b/oteltestctx/otel_test.go @@ -0,0 +1,280 @@ +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](), + oteltestctx.WithLogging[*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) TestLogging(ctx context.Context, t *testctx.T) { + // pretty annoying, not sure how to test this, just comment out to verify + t.Skip("skipping logging test since it intentionally fails") + + // Regular logs + t.Log("simple log message") + t.Logf("formatted %s message", "log") + + // Error logs + t.Error("simple error message") + t.Errorf("formatted %s message", "error") + + // Nested test with logs + t.Run("child", func(ctx context.Context, t *testctx.T) { + t.Log("child log message") + t.Error("child error message") + }) +} + +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..1c377d1 --- /dev/null +++ b/oteltestctx/trace.go @@ -0,0 +1,288 @@ +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. + if spanSocketAddr != "" { + if sc, err := requestSpanContext(spanSocketAddr, 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 +} From 6221c1056c2f4e99d9545b201fb9f68f86684ff2 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 11:51:23 -0400 Subject: [PATCH 10/21] fix(gotest): clean testify errors in span status Apply the same testify error message cleaning used in oteltestctx to gotest's extractErrorOutput. Strips verbose Error Trace and Test sections, keeping only the Error and Messages content for the span status description. Signed-off-by: Alex Suraci --- gotest/gotest.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index 23cfa50..9333614 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -243,8 +243,16 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } // extractErrorOutput cleans up test output to use as an error description. -// It strips the file:line prefix that Go's testing package adds. +// 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) @@ -262,3 +270,57 @@ func extractErrorOutput(output string) string { } 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. +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 { + // 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 "" +} From 00ba1a35ebb557fba26d220f6dc70d9b10f1758c Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 11:59:18 -0400 Subject: [PATCH 11/21] test(gotest): add error message cleaning integration tests Verify that testify-formatted error messages in test2json output are cleaned to just the Error/Messages content for span status descriptions, both standalone and mixed with regular log noise. Signed-off-by: Alex Suraci --- gotest/integration_test.go | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/gotest/integration_test.go b/gotest/integration_test.go index f1276da..628e404 100644 --- a/gotest/integration_test.go +++ b/gotest/integration_test.go @@ -16,6 +16,7 @@ import ( "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" @@ -305,6 +306,100 @@ func TestSpanContextWithMiddleware(t *testing.T) { "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 package adds " file.go:line: " to the first line; + // subsequent lines of the multi-line testify message have literal tabs. + 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. From be85da2c3128c2899b6118152652663629d475ce Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 12:10:47 -0400 Subject: [PATCH 12/21] fix(gotest): handle indented testify output from testing.decorate Go's testing.decorate adds 4 spaces of indentation to continuation lines of multi-line error messages. This caused cleanTestifyMessage to miss the tab-prefixed testify sections since lines started with spaces instead of tabs. Strip leading spaces before checking for the tab pattern. Updated integration tests to use the realistic format with the 4-space prefix. Signed-off-by: Alex Suraci --- gotest/gotest.go | 7 +++++++ gotest/integration_test.go | 18 +++++++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index 9333614..35d3e1d 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -274,6 +274,10 @@ func extractErrorOutput(output string) string { // 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 "" @@ -285,6 +289,9 @@ func cleanTestifyMessage(msg string) string { 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 diff --git a/gotest/integration_test.go b/gotest/integration_test.go index 628e404..26e7442 100644 --- a/gotest/integration_test.go +++ b/gotest/integration_test.go @@ -325,14 +325,14 @@ func TestErrorMessageCleaning(t *testing.T) { 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 package adds " file.go:line: " to the first line; - // subsequent lines of the multi-line testify message have literal tabs. + // 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", + " \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}) } @@ -376,9 +376,9 @@ func TestErrorMessageCleaningWithNoise(t *testing.T) { " 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", + " \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", } { From cea1d5db9f64d2158ca137bc21f353cd527cf74b Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 13:11:41 -0400 Subject: [PATCH 13/21] feat(otelgotest): rewrite as go test wrapper Replace the go test -exec wrapper with a drop-in go test replacement that runs go test -json internally. This preserves test caching, which the -exec approach unconditionally invalidated. The socket protocol for cross-process span context propagation now uses package-qualified keys (e.g., "example.com/pkg/TestFoo") so that a single shared socket works across all packages without collisions. The oteltestctx package auto-detects its package path at init time via debug.ReadBuildInfo. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 201 +++++++++++++------------------------ gotest/gotest.go | 9 +- gotest/integration_test.go | 38 +++++-- oteltestctx/otel.go | 5 +- oteltestctx/trace.go | 5 +- 5 files changed, 111 insertions(+), 147 deletions(-) diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go index b117823..fe4a82d 100644 --- a/cmd/otelgotest/main.go +++ b/cmd/otelgotest/main.go @@ -1,23 +1,28 @@ -// otelgotest is a go test -exec wrapper that emits OTel spans for each -// test. Usage: +// otelgotest is a drop-in go test replacement that emits OTel spans for +// each test. Usage: // -// go test -exec otelgotest ./... +// otelgotest ./... +// otelgotest -v -run TestFoo ./mypackage // -// When called by go test -exec, the first argument is the compiled test -// binary followed by -test.* flags. The wrapper runs the binary with -// -test.v=test2json, pipes stdout through go tool test2json, and feeds -// the JSON stream to gotest.Run for OTel export. +// 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. // -// The original human-readable output is reconstructed and printed to -// stdout so go test can process it normally (e.g. with -json or -v). +// Unlike the old -exec approach (go test -exec otelgotest), this +// preserves test caching. // -// If no OTLP endpoint is configured, the test binary is executed -// directly with no overhead. +// 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" - "bytes" "context" "fmt" "net" @@ -34,22 +39,26 @@ import ( ) func main() { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "usage: otelgotest [flags...]\n") - fmt.Fprintf(os.Stderr, " meant to be used with: go test -exec otelgotest\n") - os.Exit(1) - } - os.Exit(run()) } func run() int { - binary := os.Args[1] - args := os.Args[2:] + 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 exec the binary directly. + // If no OTLP endpoint, just run go test directly. if !hasOTLPEndpoint() { - return execDirect(binary, args) + return execGoTest(args) } ctx := context.Background() @@ -58,23 +67,16 @@ func run() int { tp := otelgo.GetTracerProvider() - // Replace -test.v with -test.v=test2json for structured output. - testArgs := forceTest2JSON(args) - - // Detect package name from the binary path (e.g. "foo.test" -> "foo"). - pkg := detectPackage(binary) - // Set up a Unix socket for cross-process span context propagation. - // Test binaries using oteltest can connect to this socket to retrieve - // the span context of the externally created test span, so that - // in-process operations become children of it. + // 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 execDirect(binary, args) + return execGoTest(args) } defer os.RemoveAll(tmpDir) @@ -82,48 +84,30 @@ func run() int { listener, err := net.Listen("unix", socketPath) if err != nil { fmt.Fprintf(os.Stderr, "otelgotest: socket listen: %v\n", err) - return execDirect(binary, args) + return execGoTest(args) } defer listener.Close() go serveSpanContexts(listener, registry) - // Run the test binary, capturing stdout. - testCmd := exec.Command(binary, testArgs...) - testCmd.Env = append(os.Environ(), "OTEL_TEST_SOCKET="+socketPath) - testCmd.Stderr = os.Stderr - testCmd.Stdin = os.Stdin - - testOut, err := testCmd.StdoutPipe() - if err != nil { - fmt.Fprintf(os.Stderr, "otelgotest: stdout pipe: %v\n", err) - return execDirect(binary, args) - } + // Build the go test -json command, forwarding all user args. + goTestArgs := []string{"test", "-json"} + goTestArgs = append(goTestArgs, stripJSONFlag(args)...) - // Pipe through go tool test2json to get JSON events. - test2jsonArgs := []string{"tool", "test2json"} - if pkg != "" { - test2jsonArgs = append(test2jsonArgs, "-p", pkg) - } - test2json := exec.Command("go", test2jsonArgs...) - test2json.Stdin = testOut - test2json.Stderr = os.Stderr + cmd := exec.Command("go", goTestArgs...) + cmd.Env = append(os.Environ(), "OTEL_TEST_SOCKET="+socketPath) + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin - jsonOut, err := test2json.StdoutPipe() + jsonOut, err := cmd.StdoutPipe() if err != nil { - fmt.Fprintf(os.Stderr, "otelgotest: test2json pipe: %v\n", err) - return execDirect(binary, args) - } - - if err := test2json.Start(); err != nil { - fmt.Fprintf(os.Stderr, "otelgotest: test2json start: %v\n", err) - return execDirect(binary, args) + fmt.Fprintf(os.Stderr, "otelgotest: stdout pipe: %v\n", err) + return execGoTest(args) } - if err := testCmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "otelgotest: test binary start: %v\n", err) - test2json.Process.Kill() - return execDirect(binary, args) + if err := cmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "otelgotest: start: %v\n", err) + return execGoTest(args) } // Process JSON events, writing human-readable output to stdout. @@ -136,19 +120,19 @@ func run() int { } gotest.Run(ctx, jsonOut, tp, opts...) - // Wait for both processes. - testCmd.Wait() - test2json.Wait() + cmd.Wait() - if testCmd.ProcessState != nil { - return testCmd.ProcessState.ExitCode() + if cmd.ProcessState != nil { + return cmd.ProcessState.ExitCode() } return 1 } -// execDirect runs the test binary with no instrumentation. -func execDirect(binary string, args []string) int { - cmd := exec.Command(binary, args...) +// 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 @@ -159,75 +143,28 @@ func execDirect(binary string, args []string) int { return 1 } -// forceTest2JSON replaces any -test.v flag with -test.v=test2json -// and ensures the flag is present. -func forceTest2JSON(args []string) []string { - out := []string{"-test.v=test2json"} +// stripJSONFlag removes -json from args since we add it ourselves. +func stripJSONFlag(args []string) []string { + var out []string for _, arg := range args { - if !strings.HasPrefix(arg, "-test.v") { + if arg != "-json" { out = append(out, arg) } } return out } -// detectPackage determines the full import path of the package under test. -// When invoked via go test -exec, the working directory is the package's -// source directory, so we walk up to find go.mod and combine the module -// path with the relative directory. -func detectPackage(binary string) string { - cwd, err := os.Getwd() - if err != nil { - return fallbackPackage(binary) - } - - // Walk up from cwd to find go.mod. - dir := cwd - for { - modPath, err := parseModulePath(filepath.Join(dir, "go.mod")) - if err == nil { - rel, err := filepath.Rel(dir, cwd) - if err != nil || rel == "." { - return modPath - } - return modPath + "/" + filepath.ToSlash(rel) - } - parent := filepath.Dir(dir) - if parent == dir { - break - } - dir = parent - } - - return fallbackPackage(binary) -} - -// fallbackPackage extracts a short package name from the test binary path. -func fallbackPackage(binary string) string { - base := filepath.Base(binary) - return strings.TrimSuffix(base, ".test") -} - -// parseModulePath reads the module path from a go.mod file. -func parseModulePath(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", err - } - scanner := bufio.NewScanner(bytes.NewReader(data)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if strings.HasPrefix(line, "module ") { - return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil - } - } - return "", fmt.Errorf("no module directive found in %s", path) +// 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 test name (one line), and -// the server responds with the W3C traceparent (one line). +// 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() @@ -246,12 +183,12 @@ func handleSpanContextConn(conn net.Conn, registry *gotest.SpanContextRegistry) if !scanner.Scan() { return } - testName := scanner.Text() + testKey := scanner.Text() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - sc, ok := registry.WaitFor(ctx, testName) + sc, ok := registry.WaitFor(ctx, testKey) if !ok { return } diff --git a/gotest/gotest.go b/gotest/gotest.go index 35d3e1d..244c42e 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -8,7 +8,6 @@ import ( "bufio" "context" "encoding/json" - "fmt" "io" "strings" "time" @@ -91,7 +90,11 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti for scanner.Scan() { var ev TestEvent if err := json.Unmarshal(scanner.Bytes(), &ev); err != nil { - return fmt.Errorf("decoding test event: %w", err) + // 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. @@ -174,7 +177,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti spans[key] = ts if cfg.registry != nil { - cfg.registry.Register(ev.Test, span.SpanContext()) + cfg.registry.Register(key, span.SpanContext()) } case "output": diff --git a/gotest/integration_test.go b/gotest/integration_test.go index 26e7442..d0770db 100644 --- a/gotest/integration_test.go +++ b/gotest/integration_test.go @@ -8,6 +8,8 @@ import ( "io" "net" "path/filepath" + "runtime/debug" + "strings" "testing" "time" @@ -23,6 +25,16 @@ import ( "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: // @@ -61,8 +73,9 @@ func TestSpanContextPropagation(t *testing.T) { 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. - remoteSC := requestSpanContext(t, socketPath, "TestFoo") + // 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). @@ -124,8 +137,9 @@ func TestSpanContextPropagationSubtest(t *testing.T) { enc.Encode(gotest.TestEvent{Time: now, Action: "run", Package: "example.com/pkg", Test: "TestParent/sub"}) // Retrieve span contexts for both the parent and subtest. - parentSC := requestSpanContext(t, socketPath, "TestParent") - subSC := requestSpanContext(t, socketPath, "TestParent/sub") + // 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) @@ -205,7 +219,7 @@ func TestSpanContextPropagationWaitFor(t *testing.T) { // The handler should block until the span is registered. result := make(chan trace.SpanContext, 1) go func() { - result <- requestSpanContext(t, socketPath, "TestLate") + result <- requestSpanContext(t, socketPath, "example.com/pkg/TestLate") }() // Give the socket client time to connect and block. @@ -249,6 +263,12 @@ func TestSpanContextWithMiddleware(t *testing.T) { // 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() @@ -261,11 +281,11 @@ func TestSpanContextWithMiddleware(t *testing.T) { 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: "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: "example.com/pkg", Test: fullTestName}) + 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. @@ -277,8 +297,8 @@ func TestSpanContextWithMiddleware(t *testing.T) { }) // Finish the synthetic test events. - enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg", Test: fullTestName}) - enc.Encode(gotest.TestEvent{Time: now.Add(time.Second), Action: "pass", Package: "example.com/pkg"}) + 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) diff --git a/oteltestctx/otel.go b/oteltestctx/otel.go index 552c36d..050940a 100644 --- a/oteltestctx/otel.go +++ b/oteltestctx/otel.go @@ -17,7 +17,9 @@ 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"). -var testPackage string +// 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. @@ -29,7 +31,6 @@ var testPackage string // context to subtests. func Main(m *testing.M) int { propagatedCtx = otel.InitEmbedded(context.Background(), nil) - testPackage = detectTestPackage() exitCode := m.Run() otel.Close() return exitCode diff --git a/oteltestctx/trace.go b/oteltestctx/trace.go index 1c377d1..0aa16e7 100644 --- a/oteltestctx/trace.go +++ b/oteltestctx/trace.go @@ -65,8 +65,11 @@ func WithTracing[T testctx.Runner[T]](cfg ...TraceConfig[T]) testctx.Middleware[ 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, w.Name()); err == nil && sc.IsValid() { + if sc, err := requestSpanContext(spanSocketAddr, testPackage+"/"+w.Name()); err == nil && sc.IsValid() { ctx = trace.ContextWithRemoteSpanContext(ctx, sc) next(ctx, w) return From d84ae0793e8e8136af9aa79272fe336b9109ac62 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 15:31:48 -0400 Subject: [PATCH 14/21] test(gotest): add failing test for skipped package spans Packages with no test files emit a skip action at the package level. The span is started but never ended because there is no handler for the skip case. Signed-off-by: Alex Suraci --- gotest/gotest_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go index 4def894..3137e77 100644 --- a/gotest/gotest_test.go +++ b/gotest/gotest_test.go @@ -176,6 +176,31 @@ func TestOutputCaptured(t *testing.T) { assert.True(t, found, "expected output event containing 'this test passes', got events: %v", events) } +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) From 413717e6a3a262aef57f417407d4060ee8a94e69 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 15:32:49 -0400 Subject: [PATCH 15/21] fix(gotest): end spans for skipped packages Packages with no test files emit a skip action at the package level. Handle it by ending the span with the skipped status. Signed-off-by: Alex Suraci --- gotest/gotest.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gotest/gotest.go b/gotest/gotest.go index 244c42e..3d71f3a 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -130,6 +130,13 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti ps.span.End(trace.WithTimestamp(ev.Time)) delete(pkgSpans, 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) + } } continue } From cf79fd2f12b7ef42b439cf83f09829e5823aaff6 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 16:36:10 -0400 Subject: [PATCH 16/21] fix: remove duplicate log sources causing log explosion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove AddEvent from gotest and junit — SpanStdio is the sole OTel log mechanism. Remove WithLogging from oteltestctx — gotest.Run handles test output logging externally. Previously each output line generated a span event, a SpanStdio log record from gotest.Run, and another SpanStdio log record from WithLogging, tripling OTel updates and causing O(N²) rendering in Dagger's progress view. Signed-off-by: Alex Suraci --- gotest/gotest.go | 1 - gotest/gotest_test.go | 14 --------- junit/junit.go | 6 ---- oteltestctx/log.go | 61 ---------------------------------------- oteltestctx/otel_test.go | 20 ------------- 5 files changed, 102 deletions(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index 3d71f3a..392bf6a 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -202,7 +202,6 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } ts.output.WriteString(ev.Output) - ts.span.AddEvent(trimmed, trace.WithTimestamp(ev.Time)) // Route to span logs if configured. if ts.streams != nil { diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go index 3137e77..cc3ae17 100644 --- a/gotest/gotest_test.go +++ b/gotest/gotest_test.go @@ -2,7 +2,6 @@ package gotest_test import ( "os" - "slices" "strings" "testing" @@ -163,19 +162,6 @@ func TestParallelTests(t *testing.T) { assert.Equal(t, codes.Ok, b.Status().Code) } -func TestOutputCaptured(t *testing.T) { - spans := runFixture(t) - - span := findSpan(spans, "TestPass") - require.NotNil(t, span) - - events := span.Events() - found := slices.ContainsFunc(events, func(e sdktrace.Event) bool { - return strings.Contains(e.Name, "this test passes") - }) - assert.True(t, found, "expected output event containing 'this test passes', got events: %v", events) -} - func TestSkippedPackage(t *testing.T) { spanRecorder := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) diff --git a/junit/junit.go b/junit/junit.go index c3ad161..0e47020 100644 --- a/junit/junit.go +++ b/junit/junit.go @@ -6,7 +6,6 @@ package junit import ( "context" "io" - "strings" "time" otel "github.com/dagger/otel-go" @@ -152,11 +151,6 @@ func emitTest(ctx context.Context, tracer trace.Tracer, cfg *runConfig, suiteNam streams.Close() } - // Emit output as span events too. - if test.SystemOut != "" { - span.AddEvent(strings.TrimSpace(test.SystemOut)) - } - switch test.Status { case junitparser.StatusPassed: span.SetStatus(codes.Ok, "") diff --git a/oteltestctx/log.go b/oteltestctx/log.go index a71f3f3..e768f39 100644 --- a/oteltestctx/log.go +++ b/oteltestctx/log.go @@ -1,62 +1 @@ package oteltestctx - -import ( - "context" - "fmt" - - otel "github.com/dagger/otel-go" - "github.com/dagger/testctx" - "go.opentelemetry.io/otel/log" - sdklog "go.opentelemetry.io/otel/sdk/log" -) - -// LogConfig holds configuration for the OpenTelemetry logging middleware -type LogConfig struct { - // LoggerProvider to use for logging. If nil, the global provider will be used. - LoggerProvider *sdklog.LoggerProvider - // Attributes to add to all test logs - Attributes []log.KeyValue -} - -// WithLogging creates middleware that adds OpenTelemetry logging to each test/benchmark -func WithLogging[T testctx.Runner[T]](cfg ...LogConfig) testctx.Middleware[T] { - var c LogConfig - if len(cfg) > 0 { - c = cfg[0] - } - if c.LoggerProvider == nil { - c.LoggerProvider = otel.LoggerProvider(propagatedCtx) - } - - return func(next testctx.RunFunc[T]) testctx.RunFunc[T] { - return func(ctx context.Context, w *testctx.W[T]) { - // Use the same logger provider as the main test - ctx = otel.WithLoggerProvider(ctx, c.LoggerProvider) - - // Send logs to the span - next(ctx, w.WithLogger(&spanLogger{ - streams: otel.SpanStdio(ctx, instrumentationLibrary, c.Attributes...), - })) - } - } -} - -type spanLogger struct { - streams otel.SpanStreams -} - -func (l *spanLogger) Log(args ...any) { - fmt.Fprintln(l.streams.Stdout, args...) -} - -func (l *spanLogger) Logf(format string, args ...any) { - fmt.Fprintf(l.streams.Stdout, format+"\n", args...) -} - -func (l *spanLogger) Error(args ...any) { - fmt.Fprintln(l.streams.Stderr, args...) -} - -func (l *spanLogger) Errorf(format string, args ...any) { - fmt.Fprintf(l.streams.Stderr, format+"\n", args...) -} diff --git a/oteltestctx/otel_test.go b/oteltestctx/otel_test.go index 4e27d71..8c77927 100644 --- a/oteltestctx/otel_test.go +++ b/oteltestctx/otel_test.go @@ -25,7 +25,6 @@ func TestOTel(t *testing.T) { testctx.New(t, testctx.WithParallel(), oteltestctx.WithTracing[*testing.T](), - oteltestctx.WithLogging[*testing.T](), ).RunTests(OTelSuite{}) } @@ -227,25 +226,6 @@ func (OTelSuite) TestTracingNesting(ctx context.Context, t *testctx.T) { assert.True(t, child.EndTime().Before(parent.EndTime())) } -func (OTelSuite) TestLogging(ctx context.Context, t *testctx.T) { - // pretty annoying, not sure how to test this, just comment out to verify - t.Skip("skipping logging test since it intentionally fails") - - // Regular logs - t.Log("simple log message") - t.Logf("formatted %s message", "log") - - // Error logs - t.Error("simple error message") - t.Errorf("formatted %s message", "error") - - // Nested test with logs - t.Run("child", func(ctx context.Context, t *testctx.T) { - t.Log("child log message") - t.Error("child error message") - }) -} - func (OTelSuite) TestInterrupted(ctx context.Context, t *testctx.T) { spanRecorder := tracetest.NewSpanRecorder() tracerProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) From 4fd036c7abf6332f6ac67965af5536efbb138e9d Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 17:45:58 -0400 Subject: [PATCH 17/21] feat(gotest): add WithVerbose option to control output go test -json always forces -test.v=test2json, making testing.Verbose() return true. While we can't change that, we can control the reconstructed human-readable output. In non-verbose mode (the default), per-test output is buffered and only flushed for failing tests. Package-level output passes through unconditionally. otelgotest now detects -v in user args and sets WithVerbose accordingly. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 16 +++++++++++++++ gotest/gotest.go | 44 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go index fe4a82d..e3696da 100644 --- a/cmd/otelgotest/main.go +++ b/cmd/otelgotest/main.go @@ -113,6 +113,7 @@ func run() int { // Process JSON events, writing human-readable output to stdout. opts := []gotest.Option{ gotest.WithOutput(os.Stdout), + gotest.WithVerbose(hasVerboseFlag(args)), gotest.WithSpanContextRegistry(registry), } if lp := otel.LoggerProvider(ctx); lp != nil { @@ -200,6 +201,21 @@ 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 + } + // Stop at -- or first non-flag argument. + 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/gotest/gotest.go b/gotest/gotest.go index 392bf6a..53feb1f 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -34,10 +34,11 @@ type TestEvent struct { // testSpan tracks an in-flight span for a single test. type testSpan struct { - span trace.Span - ctx context.Context - output strings.Builder - streams *otel.SpanStreams + span trace.Span + ctx context.Context + output strings.Builder + streams *otel.SpanStreams + bufferedOut strings.Builder // buffered output for non-verbose mode } // Option configures the behavior of Run. @@ -45,6 +46,7 @@ type Option func(*runConfig) type runConfig struct { output io.Writer + verbose bool loggerProvider *sdklog.LoggerProvider registry *SpanContextRegistry } @@ -52,10 +54,26 @@ type runConfig struct { // 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. @@ -98,8 +116,18 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } // 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 != "" { - io.WriteString(cfg.output, 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 { + io.WriteString(cfg.output, ev.Output) + } } // Handle package-level events (no test name). @@ -214,6 +242,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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)) @@ -225,6 +254,10 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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" @@ -240,6 +273,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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)) From 961e254750b360f0b83d1f1b1a6ea67f7e010be1 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 18:24:57 -0400 Subject: [PATCH 18/21] fix(gotest): suppress bare PASS line in non-verbose mode Signed-off-by: Alex Suraci --- gotest/gotest.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index 53feb1f..e3599cb 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -125,7 +125,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti if ts, ok := spans[key]; ok { ts.bufferedOut.WriteString(ev.Output) } - } else { + } else if cfg.verbose || strings.TrimSpace(ev.Output) != "PASS" { io.WriteString(cfg.output, ev.Output) } } From 80db499b859597bf77cac24acd8a71b6066b4660 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Tue, 7 Apr 2026 22:53:05 -0400 Subject: [PATCH 19/21] fix(otelgotest): support leading -C Keep a leading go -C flag ahead of the injected -json flag so commands like otelgotest -C ./foo/bar ./... translate to a valid go test invocation. Add unit coverage for both -C forms and the existing non--C behavior. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 41 +++++++++++++++++++++++++++++-- cmd/otelgotest/main_test.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 cmd/otelgotest/main_test.go diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go index e3696da..5c011e9 100644 --- a/cmd/otelgotest/main.go +++ b/cmd/otelgotest/main.go @@ -91,8 +91,9 @@ func run() int { go serveSpanContexts(listener, registry) // Build the go test -json command, forwarding all user args. - goTestArgs := []string{"test", "-json"} - goTestArgs = append(goTestArgs, stripJSONFlag(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) @@ -144,6 +145,42 @@ func execGoTest(args []string) int { 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 -json from args since we add it ourselves. func stripJSONFlag(args []string) []string { var out []string diff --git a/cmd/otelgotest/main_test.go b/cmd/otelgotest/main_test.go new file mode 100644 index 0000000..b3f9790 --- /dev/null +++ b/cmd/otelgotest/main_test.go @@ -0,0 +1,49 @@ +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: "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) + } + }) + } +} From 0fcfffbd74434d1e92ed49a2e600c12c527426c0 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Wed, 8 Apr 2026 20:14:09 -0400 Subject: [PATCH 20/21] fix(gotest): handle slashy subtest names Derive test hierarchy from go test -json run, pause, cont, and completion events instead of splitting ev.Test on '/'. This keeps slash-containing leaf names attached to their real parents and avoids misparenting ambiguous or interleaved parallel subtests. Signed-off-by: Alex Suraci --- gotest/gotest.go | 78 +++++++++++--- gotest/gotest_test.go | 212 +++++++++++++++++++++++++++++++++++++ gotest/integration_test.go | 15 +-- 3 files changed, 278 insertions(+), 27 deletions(-) diff --git a/gotest/gotest.go b/gotest/gotest.go index e3599cb..1f1dbb3 100644 --- a/gotest/gotest.go +++ b/gotest/gotest.go @@ -34,11 +34,12 @@ type TestEvent struct { // testSpan tracks an in-flight span for a single test. type testSpan struct { - span trace.Span - ctx context.Context - output strings.Builder - streams *otel.SpanStreams - bufferedOut strings.Builder // buffered output for non-verbose mode + 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. @@ -98,9 +99,16 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti tracer := tp.Tracer(instrumentationLibrary) - // key: "package/TestName" or "package/TestName/sub" + // 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{} @@ -151,6 +159,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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") @@ -158,6 +167,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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") @@ -165,6 +175,7 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti ps.span.End(trace.WithTimestamp(ev.Time)) delete(pkgSpans, ev.Package) } + delete(activeTests, ev.Package) } continue } @@ -175,21 +186,29 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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 } - // For subtests, find the parent test span. - if idx := strings.LastIndex(ev.Test, "/"); idx != -1 { - parentKey := ev.Package + "/" + ev.Test[:idx] - if ps, ok := spans[parentKey]; 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) + } } } - // Span name is the base name (leaf). spanName := ev.Test - if idx := strings.LastIndex(ev.Test, "/"); idx != -1 { - spanName = ev.Test[idx+1:] + if parentName != "" { + spanName = strings.TrimPrefix(ev.Test, parentName+"/") } spanCtx, span := tracer.Start(parentCtx, spanName, @@ -201,8 +220,9 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti ) ts := &testSpan{ - span: span, - ctx: spanCtx, + span: span, + ctx: spanCtx, + testName: ev.Test, } if cfg.loggerProvider != nil { spanCtx = otel.WithLoggerProvider(spanCtx, cfg.loggerProvider) @@ -210,11 +230,28 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti 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. @@ -238,6 +275,9 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } 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() @@ -250,6 +290,9 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } 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() @@ -269,6 +312,9 @@ func Run(ctx context.Context, r io.Reader, tp trace.TracerProvider, opts ...Opti } 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() diff --git a/gotest/gotest_test.go b/gotest/gotest_test.go index cc3ae17..1be220a 100644 --- a/gotest/gotest_test.go +++ b/gotest/gotest_test.go @@ -1,9 +1,12 @@ package gotest_test import ( + "bytes" + "encoding/json" "os" "strings" "testing" + "time" "github.com/dagger/otel-go/gotest" "github.com/stretchr/testify/assert" @@ -41,6 +44,69 @@ func findSpan(spans []sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan 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 { @@ -124,6 +190,152 @@ func TestSubtestNesting(t *testing.T) { 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) diff --git a/gotest/integration_test.go b/gotest/integration_test.go index d0770db..4010467 100644 --- a/gotest/integration_test.go +++ b/gotest/integration_test.go @@ -306,17 +306,10 @@ func TestSpanContextWithMiddleware(t *testing.T) { // Find the spans. spans := spanRecorder.Ended() - var gotestSpan sdktrace.ReadOnlySpan - var downstreamSpan sdktrace.ReadOnlySpan - for _, s := range spans { - switch s.Name() { - case "inner": - gotestSpan = s - case "downstream-call": - downstreamSpan = s - } - } - require.NotNil(t, gotestSpan, "expected gotest span 'inner'") + 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. From a52f62c67f62eafd080494672c328be0db8b0b93 Mon Sep 17 00:00:00 2001 From: Alex Suraci Date: Thu, 9 Apr 2026 13:04:12 -0400 Subject: [PATCH 21/21] fix(otelgotest): respect -json output When users pass -json, keep printing the raw go test JSON stream to stdout while still parsing it for spans. This preserves the requested output mode the same way we already handle -v. Signed-off-by: Alex Suraci --- cmd/otelgotest/main.go | 34 +++++++++++++++++++++++++++------- cmd/otelgotest/main_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/cmd/otelgotest/main.go b/cmd/otelgotest/main.go index 5c011e9..061bf97 100644 --- a/cmd/otelgotest/main.go +++ b/cmd/otelgotest/main.go @@ -25,6 +25,7 @@ import ( "bufio" "context" "fmt" + "io" "net" "os" "os/exec" @@ -111,16 +112,23 @@ func run() int { return execGoTest(args) } - // Process JSON events, writing human-readable output to stdout. + // Process JSON events while preserving the user's requested output mode. + stream := io.Reader(jsonOut) opts := []gotest.Option{ - gotest.WithOutput(os.Stdout), - gotest.WithVerbose(hasVerboseFlag(args)), 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, jsonOut, tp, opts...) + gotest.Run(ctx, stream, tp, opts...) cmd.Wait() @@ -181,11 +189,11 @@ func splitLeadingChdirFlags(args []string) (chdirArgs, rest []string) { return chdirArgs, rest } -// stripJSONFlag removes -json from args since we add it ourselves. +// 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" { + if arg != "-json" && arg != "-json=true" && arg != "-json=false" { out = append(out, arg) } } @@ -245,7 +253,19 @@ func hasVerboseFlag(args []string) bool { arg == "-v=true" || arg == "-test.v=true" { return true } - // Stop at -- or first non-flag argument. + 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 } diff --git a/cmd/otelgotest/main_test.go b/cmd/otelgotest/main_test.go index b3f9790..4f39fe9 100644 --- a/cmd/otelgotest/main_test.go +++ b/cmd/otelgotest/main_test.go @@ -31,6 +31,16 @@ func TestBuildGoTestArgs(t *testing.T) { 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", "./..."}, @@ -47,3 +57,24 @@ func TestBuildGoTestArgs(t *testing.T) { }) } } + +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) + } + }) + } +}