diff --git a/cmd/build.go b/cmd/build.go index 7a4a7f7..13cea9b 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -206,6 +206,7 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().UintP("max-concurrent-tasks", "j", uint(cpus), "Limit the number of max concurrent build tasks - set to 0 to disable the limit") cmd.Flags().String("coverage-output-path", "", "Output path where test coverage file will be copied after running tests") cmd.Flags().Bool("disable-coverage", false, "Disable test coverage collection (defaults to false)") + cmd.Flags().Bool("enable-test-tracing", false, "Enable per-test OpenTelemetry span creation (defaults to false)") cmd.Flags().StringToString("docker-build-options", nil, "Options passed to all 'docker build' commands") cmd.Flags().Bool("slsa-cache-verification", false, "Enable SLSA verification for cached artifacts") cmd.Flags().String("slsa-source-uri", "", "Expected source URI for SLSA verification (required when verification enabled)") @@ -394,6 +395,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache, C } disableCoverage, _ := cmd.Flags().GetBool("disable-coverage") + enableTestTracing, _ := cmd.Flags().GetBool("enable-test-tracing") var dockerBuildOptions leeway.DockerBuildOptions dockerBuildOptions, err = cmd.Flags().GetStringToString("docker-build-options") @@ -459,6 +461,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache, C leeway.WithCompressionDisabled(dontCompress), leeway.WithFixedBuildDir(fixedBuildDir), leeway.WithDisableCoverage(disableCoverage), + leeway.WithEnableTestTracing(enableTestTracing), leeway.WithInFlightChecksums(inFlightChecksums), leeway.WithDockerExportToCache(dockerExportToCache, dockerExportSet), leeway.WithDockerExportEnv(dockerExportEnvValue, dockerExportEnvSet), diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index 779c92b..733ef98 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -25,6 +25,7 @@ import ( "github.com/in-toto/in-toto-golang/in_toto" "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/trace" "golang.org/x/mod/modfile" "golang.org/x/sync/errgroup" "golang.org/x/sync/semaphore" @@ -480,6 +481,7 @@ type buildOptions struct { JailedExecution bool UseFixedBuildDir bool DisableCoverage bool + EnableTestTracing bool InFlightChecksums bool DockerExportToCache bool DockerExportSet bool // Track if explicitly set via CLI flag @@ -604,6 +606,14 @@ func WithDisableCoverage(disableCoverage bool) BuildOption { } } +// WithEnableTestTracing enables per-test OpenTelemetry span creation during Go test execution +func WithEnableTestTracing(enable bool) BuildOption { + return func(opts *buildOptions) error { + opts.EnableTestTracing = enable + return nil + } +} + // WithInFlightChecksums enables checksumming of cache artifacts to prevent TOCTU attacks func WithInFlightChecksums(enabled bool) BuildOption { return func(opts *buildOptions) error { @@ -3056,7 +3066,7 @@ func executeCommandsForPackage(buildctx *buildContext, p *Package, wd string, co if len(cmd) == 0 { continue // Skip empty commands } - err := run(buildctx.Reporter, p, env, wd, cmd[0], cmd[1:]...) + err := run(buildctx, p, env, wd, cmd[0], cmd[1:]...) if err != nil { return err } @@ -3064,21 +3074,93 @@ func executeCommandsForPackage(buildctx *buildContext, p *Package, wd string, co return nil } -func run(rep Reporter, p *Package, env []string, cwd, name string, args ...string) error { +// isGoTestCommand checks if the command is a "go test" invocation +func isGoTestCommand(name string, args []string) bool { + if name != "go" { + return false + } + for _, arg := range args { + if arg == "test" { + return true + } + // Stop at first non-flag argument that isn't "test" + if !strings.HasPrefix(arg, "-") { + return false + } + } + return false +} + +func run(buildctx *buildContext, p *Package, env []string, cwd, name string, args ...string) error { log.WithField("package", p.FullName()).WithField("command", strings.Join(append([]string{name}, args...), " ")).Debug("running") + // Check if this is a go test command and tracing is enabled + if buildctx != nil && buildctx.EnableTestTracing && isGoTestCommand(name, args) { + if otelRep, ok := findOTelReporter(buildctx.Reporter); ok { + if ctx := otelRep.GetPackageContext(p); ctx != nil { + return runGoTestWithTracing(buildctx, p, env, cwd, name, args, otelRep.GetTracer(), ctx) + } + } + } + + // Standard command execution cmd := exec.Command(name, args...) - cmd.Stdout = &reporterStream{R: rep, P: p, IsErr: false} - cmd.Stderr = &reporterStream{R: rep, P: p, IsErr: true} + if buildctx != nil { + cmd.Stdout = &reporterStream{R: buildctx.Reporter, P: p, IsErr: false} + cmd.Stderr = &reporterStream{R: buildctx.Reporter, P: p, IsErr: true} + } cmd.Dir = cwd cmd.Env = env - err := cmd.Run() + return cmd.Run() +} + +// findOTelReporter finds an OTelReporter in the reporter chain +func findOTelReporter(rep Reporter) (*OTelReporter, bool) { + switch r := rep.(type) { + case *OTelReporter: + return r, true + case CompositeReporter: + for _, inner := range r { + if otel, ok := findOTelReporter(inner); ok { + return otel, ok + } + } + } + return nil, false +} + +// runGoTestWithTracing runs go test with JSON output and creates spans for each test +func runGoTestWithTracing(buildctx *buildContext, p *Package, env []string, cwd, name string, args []string, tracer trace.Tracer, parentCtx context.Context) error { + // Build command with -json flag + fullArgs := append([]string{name}, args...) + jsonArgs := ensureJSONFlag(fullArgs) + + log.WithField("package", p.FullName()).WithField("command", strings.Join(jsonArgs, " ")).Debug("running go test with tracing") + + cmd := exec.Command(jsonArgs[0], jsonArgs[1:]...) + cmd.Dir = cwd + cmd.Env = env + + stdout, err := cmd.StdoutPipe() if err != nil { - return err + return fmt.Errorf("failed to create stdout pipe: %w", err) } + cmd.Stderr = &reporterStream{R: buildctx.Reporter, P: p, IsErr: true} - return nil + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start go test: %w", err) + } + + // Create tracer and parse output + goTracer := NewGoTestTracer(tracer, parentCtx) + outputWriter := &reporterStream{R: buildctx.Reporter, P: p, IsErr: false} + + if err := goTracer.parseJSONOutput(stdout, outputWriter); err != nil { + log.WithError(err).Warn("error parsing go test JSON output") + } + + return cmd.Wait() } type reporterStream struct { diff --git a/pkg/leeway/gotest_trace.go b/pkg/leeway/gotest_trace.go new file mode 100644 index 0000000..9e4abc4 --- /dev/null +++ b/pkg/leeway/gotest_trace.go @@ -0,0 +1,360 @@ +package leeway + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// goTestEvent represents a single event from `go test -json` output. +// See https://pkg.go.dev/cmd/test2json for the format specification. +type goTestEvent struct { + Time time.Time `json:"Time"` + Action string `json:"Action"` + Package string `json:"Package"` + Test string `json:"Test"` + Output string `json:"Output"` + Elapsed float64 `json:"Elapsed"` // seconds + FailedBuild string `json:"FailedBuild"` // package that failed to build (when Action == "fail") +} + +// testSpanData holds the span for an in-progress test +type testSpanData struct { + span trace.Span +} + +// GoTestTracer handles parsing Go test JSON output and creating OpenTelemetry spans +type GoTestTracer struct { + tracer trace.Tracer + parentCtx context.Context + + mu sync.Mutex + spans map[string]*testSpanData // key: "package/testname" or just "package" for package-level +} + +// NewGoTestTracer creates a new GoTestTracer that will create spans as children of the given context +func NewGoTestTracer(tracer trace.Tracer, parentCtx context.Context) *GoTestTracer { + return &GoTestTracer{ + tracer: tracer, + parentCtx: parentCtx, + spans: make(map[string]*testSpanData), + } +} + +// spanKey generates a unique key for a test span +func spanKey(pkg, test string) string { + if test == "" { + return pkg + } + return pkg + "/" + test +} + +// parseJSONOutput reads JSON events from the reader and creates/ends spans accordingly +func (t *GoTestTracer) parseJSONOutput(r io.Reader, outputWriter io.Writer) error { + scanner := bufio.NewScanner(r) + // Increase buffer size for long output lines + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + verbose := log.IsLevelEnabled(log.DebugLevel) + + // Buffer output for each test so we can show it on failure + testOutput := make(map[string][]string) + + for scanner.Scan() { + line := scanner.Bytes() + + var event goTestEvent + if err := json.Unmarshal(line, &event); err != nil { + // Not valid JSON, write as-is (shouldn't happen with -json flag) + _, _ = outputWriter.Write(line) + _, _ = outputWriter.Write([]byte("\n")) + continue + } + + // Handle output based on verbosity + if event.Output != "" { + if verbose { + // Verbose mode: show all output + _, _ = outputWriter.Write([]byte(event.Output)) + } else if event.Test == "" { + // Non-verbose: always show package-level output + _, _ = outputWriter.Write([]byte(event.Output)) + } else { + // Non-verbose: buffer test output in case of failure + key := spanKey(event.Package, event.Test) + testOutput[key] = append(testOutput[key], event.Output) + } + } + + // On test failure, flush buffered output (non-verbose mode only) + if !verbose && event.Action == "fail" && event.Test != "" { + key := spanKey(event.Package, event.Test) + if output, ok := testOutput[key]; ok { + for _, line := range output { + _, _ = outputWriter.Write([]byte(line)) + } + delete(testOutput, key) + } + } + + // Clean up buffer on test completion (pass/skip) + if event.Action == "pass" || event.Action == "skip" { + if event.Test != "" { + key := spanKey(event.Package, event.Test) + delete(testOutput, key) + } + } + + // Handle the event for span creation + t.handleEvent(&event) + } + + // End any remaining spans (in case of abnormal termination) + t.endAllSpans() + + return scanner.Err() +} + +// handleEvent processes a single go test event +func (t *GoTestTracer) handleEvent(event *goTestEvent) { + switch event.Action { + case "run": + t.handleRun(event) + case "pause": + t.handlePause(event) + case "cont": + t.handleCont(event) + case "pass", "fail", "skip": + t.handleEnd(event) + case "output": + // Output is already written to outputWriter + case "start": + // Package test started - we could create a package-level span here + t.handlePackageStart(event) + } +} + +// handleRun creates a new span for a test that started running +func (t *GoTestTracer) handleRun(event *goTestEvent) { + if event.Test == "" || t.tracer == nil { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + key := spanKey(event.Package, event.Test) + + // Create span with the test start time + _, span := t.tracer.Start(t.parentCtx, formatTestSpanName(event.Package, event.Test), + trace.WithTimestamp(event.Time), + trace.WithSpanKind(trace.SpanKindInternal), + ) + + span.SetAttributes( + attribute.String("test.name", event.Test), + attribute.String("test.package", event.Package), + attribute.String("test.framework", "go"), + ) + + t.spans[key] = &testSpanData{span: span} +} + +// handlePackageStart creates a span for package-level test execution +func (t *GoTestTracer) handlePackageStart(event *goTestEvent) { + if event.Package == "" || t.tracer == nil { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + key := spanKey(event.Package, "") + + // Only create if not already exists + if _, exists := t.spans[key]; exists { + return + } + + _, span := t.tracer.Start(t.parentCtx, fmt.Sprintf("package: %s", event.Package), + trace.WithTimestamp(event.Time), + trace.WithSpanKind(trace.SpanKindInternal), + ) + + span.SetAttributes( + attribute.String("test.package", event.Package), + attribute.String("test.framework", "go"), + attribute.String("test.scope", "package"), + ) + + t.spans[key] = &testSpanData{span: span} +} + +// handlePause records that a test was paused (for t.Parallel()) +func (t *GoTestTracer) handlePause(event *goTestEvent) { + if event.Test == "" { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + key := spanKey(event.Package, event.Test) + if data, ok := t.spans[key]; ok { + data.span.AddEvent("test.paused", trace.WithTimestamp(event.Time)) + } +} + +// handleCont records that a paused test continued +func (t *GoTestTracer) handleCont(event *goTestEvent) { + if event.Test == "" { + return + } + + t.mu.Lock() + defer t.mu.Unlock() + + key := spanKey(event.Package, event.Test) + if data, ok := t.spans[key]; ok { + data.span.AddEvent("test.continued", trace.WithTimestamp(event.Time)) + } +} + +// handleEnd ends a span for a completed test +func (t *GoTestTracer) handleEnd(event *goTestEvent) { + t.mu.Lock() + defer t.mu.Unlock() + + // Handle test-level completion + if event.Test != "" { + key := spanKey(event.Package, event.Test) + if data, ok := t.spans[key]; ok { + // Set status based on action + switch event.Action { + case "pass": + data.span.SetStatus(codes.Ok, "") + data.span.SetAttributes(attribute.String("test.status", "passed")) + case "fail": + if event.FailedBuild != "" { + data.span.SetStatus(codes.Error, "build failed") + data.span.SetAttributes( + attribute.String("test.status", "build_failed"), + attribute.String("test.failed_build", event.FailedBuild), + ) + } else { + data.span.SetStatus(codes.Error, "test failed") + data.span.SetAttributes(attribute.String("test.status", "failed")) + } + case "skip": + data.span.SetStatus(codes.Ok, "test skipped") + data.span.SetAttributes(attribute.String("test.status", "skipped")) + } + + // Add elapsed time if available + if event.Elapsed > 0 { + data.span.SetAttributes(attribute.Float64("test.elapsed_seconds", event.Elapsed)) + } + + data.span.End(trace.WithTimestamp(event.Time)) + delete(t.spans, key) + } + return + } + + // Handle package-level completion (event.Test is empty) + if event.Package != "" { + key := spanKey(event.Package, "") + if data, ok := t.spans[key]; ok { + switch event.Action { + case "pass": + data.span.SetStatus(codes.Ok, "") + data.span.SetAttributes(attribute.String("test.status", "passed")) + case "fail": + if event.FailedBuild != "" { + data.span.SetStatus(codes.Error, "build failed") + data.span.SetAttributes( + attribute.String("test.status", "build_failed"), + attribute.String("test.failed_build", event.FailedBuild), + ) + } else { + data.span.SetStatus(codes.Error, "package tests failed") + data.span.SetAttributes(attribute.String("test.status", "failed")) + } + case "skip": + data.span.SetStatus(codes.Ok, "package tests skipped") + data.span.SetAttributes(attribute.String("test.status", "skipped")) + } + + if event.Elapsed > 0 { + data.span.SetAttributes(attribute.Float64("test.elapsed_seconds", event.Elapsed)) + } + + data.span.End(trace.WithTimestamp(event.Time)) + delete(t.spans, key) + } + } +} + +// endAllSpans ends any remaining open spans (cleanup for abnormal termination) +func (t *GoTestTracer) endAllSpans() { + t.mu.Lock() + defer t.mu.Unlock() + + for key, data := range t.spans { + data.span.SetStatus(codes.Error, "test did not complete") + data.span.End() + delete(t.spans, key) + } +} + +// formatTestSpanName creates a readable span name for a test +func formatTestSpanName(pkg, test string) string { + // Extract just the package name without the full module path + parts := strings.Split(pkg, "/") + shortPkg := parts[len(parts)-1] + + return fmt.Sprintf("test: %s/%s", shortPkg, test) +} + +// ensureJSONFlag ensures the -json flag is present in the test arguments +func ensureJSONFlag(args []string) []string { + for _, arg := range args { + if arg == "-json" { + return args + } + } + + // Insert -json after "test" command + result := make([]string, 0, len(args)+1) + for i, arg := range args { + result = append(result, arg) + if arg == "test" && i < len(args)-1 { + result = append(result, "-json") + } + } + + // If "test" wasn't found, just append -json + hasJSON := false + for _, arg := range result { + if arg == "-json" { + hasJSON = true + break + } + } + if !hasJSON { + result = append(result, "-json") + } + + return result +} diff --git a/pkg/leeway/gotest_trace_test.go b/pkg/leeway/gotest_trace_test.go new file mode 100644 index 0000000..c67b383 --- /dev/null +++ b/pkg/leeway/gotest_trace_test.go @@ -0,0 +1,343 @@ +package leeway + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestGoTestTracer_ParseJSONOutput(t *testing.T) { + // Create a test tracer provider with in-memory exporter + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider( + sdktrace.WithSyncer(exporter), + ) + defer func() { _ = tp.Shutdown(context.Background()) }() + + tracer := tp.Tracer("test") + ctx, parentSpan := tracer.Start(context.Background(), "parent") + defer parentSpan.End() + + goTracer := NewGoTestTracer(tracer, ctx) + + // Simulate go test -json output + jsonOutput := `{"Time":"2024-01-01T10:00:00Z","Action":"start","Package":"example.com/pkg"} +{"Time":"2024-01-01T10:00:00.001Z","Action":"run","Package":"example.com/pkg","Test":"TestOne"} +{"Time":"2024-01-01T10:00:00.002Z","Action":"output","Package":"example.com/pkg","Test":"TestOne","Output":"=== RUN TestOne\n"} +{"Time":"2024-01-01T10:00:00.100Z","Action":"output","Package":"example.com/pkg","Test":"TestOne","Output":"--- PASS: TestOne (0.10s)\n"} +{"Time":"2024-01-01T10:00:00.100Z","Action":"pass","Package":"example.com/pkg","Test":"TestOne","Elapsed":0.1} +{"Time":"2024-01-01T10:00:00.101Z","Action":"run","Package":"example.com/pkg","Test":"TestTwo"} +{"Time":"2024-01-01T10:00:00.150Z","Action":"output","Package":"example.com/pkg","Test":"TestTwo","Output":" test_two.go:10: assertion failed\n"} +{"Time":"2024-01-01T10:00:00.200Z","Action":"fail","Package":"example.com/pkg","Test":"TestTwo","Elapsed":0.1} +{"Time":"2024-01-01T10:00:00.201Z","Action":"run","Package":"example.com/pkg","Test":"TestThree"} +{"Time":"2024-01-01T10:00:00.250Z","Action":"skip","Package":"example.com/pkg","Test":"TestThree","Elapsed":0.05} +{"Time":"2024-01-01T10:00:00.299Z","Action":"output","Package":"example.com/pkg","Output":"PASS\n"} +{"Time":"2024-01-01T10:00:00.300Z","Action":"output","Package":"example.com/pkg","Output":"ok \texample.com/pkg\t0.3s\n"} +{"Time":"2024-01-01T10:00:00.300Z","Action":"pass","Package":"example.com/pkg","Elapsed":0.3} +` + + var outputBuf bytes.Buffer + err := goTracer.parseJSONOutput(strings.NewReader(jsonOutput), &outputBuf) + if err != nil { + t.Fatalf("parseJSONOutput failed: %v", err) + } + + // End parent span to flush + parentSpan.End() + + // Check that spans were created + spans := exporter.GetSpans() + + // We expect: parent span + package span + 3 test spans = 5 spans + // But the parent span is ended after, so we check for at least 4 + if len(spans) < 4 { + t.Errorf("expected at least 4 spans, got %d", len(spans)) + for i, s := range spans { + t.Logf("span %d: %s", i, s.Name) + } + } + + // Verify test spans exist with correct names + spanNames := make(map[string]bool) + for _, s := range spans { + spanNames[s.Name] = true + } + + expectedSpans := []string{ + "test: pkg/TestOne", + "test: pkg/TestTwo", + "test: pkg/TestThree", + "package: example.com/pkg", + } + + for _, expected := range expectedSpans { + if !spanNames[expected] { + t.Errorf("expected span %q not found", expected) + } + } + + // Verify span statuses + for _, s := range spans { + switch s.Name { + case "test: pkg/TestOne": + if s.Status.Code != codes.Ok { + t.Errorf("TestOne should have Ok status, got %v", s.Status.Code) + } + case "test: pkg/TestTwo": + if s.Status.Code != codes.Error { + t.Errorf("TestTwo should have Error status, got %v", s.Status.Code) + } + case "test: pkg/TestThree": + if s.Status.Code != codes.Ok { + t.Errorf("TestThree (skipped) should have Ok status, got %v", s.Status.Code) + } + } + } + + // Verify output was written + output := outputBuf.String() + // Package-level output should always be shown + if !strings.Contains(output, "ok \texample.com/pkg") { + t.Error("expected package summary output to be written") + } + // Failed test output should be shown (TestTwo failed) + if !strings.Contains(output, "assertion failed") { + t.Error("expected failed test output to be written") + } +} + +func TestGoTestTracer_ParallelTests(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider( + sdktrace.WithSyncer(exporter), + ) + defer func() { _ = tp.Shutdown(context.Background()) }() + + tracer := tp.Tracer("test") + ctx, parentSpan := tracer.Start(context.Background(), "parent") + defer parentSpan.End() + + goTracer := NewGoTestTracer(tracer, ctx) + + // Simulate parallel test execution with pause/cont events + jsonOutput := `{"Time":"2024-01-01T10:00:00Z","Action":"run","Package":"example.com/pkg","Test":"TestParallel"} +{"Time":"2024-01-01T10:00:00.001Z","Action":"pause","Package":"example.com/pkg","Test":"TestParallel"} +{"Time":"2024-01-01T10:00:00.100Z","Action":"cont","Package":"example.com/pkg","Test":"TestParallel"} +{"Time":"2024-01-01T10:00:00.200Z","Action":"pass","Package":"example.com/pkg","Test":"TestParallel","Elapsed":0.2} +` + + var outputBuf bytes.Buffer + err := goTracer.parseJSONOutput(strings.NewReader(jsonOutput), &outputBuf) + if err != nil { + t.Fatalf("parseJSONOutput failed: %v", err) + } + + parentSpan.End() + spans := exporter.GetSpans() + + // Find the test span + var testSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "test: pkg/TestParallel" { + testSpan = &spans[i] + break + } + } + + if testSpan == nil { + t.Fatal("TestParallel span not found") + } + + // Verify pause and cont events were recorded + eventNames := make([]string, 0) + for _, e := range testSpan.Events { + eventNames = append(eventNames, e.Name) + } + + if len(eventNames) != 2 { + t.Errorf("expected 2 events (pause, cont), got %d: %v", len(eventNames), eventNames) + } +} + +func TestGoTestTracer_NoTracer(t *testing.T) { + // Test that nil tracer doesn't panic + goTracer := NewGoTestTracer(nil, context.Background()) + + jsonOutput := `{"Time":"2024-01-01T10:00:00Z","Action":"run","Package":"example.com/pkg","Test":"TestOne"} +{"Time":"2024-01-01T10:00:00.100Z","Action":"pass","Package":"example.com/pkg","Test":"TestOne","Elapsed":0.1} +` + + var outputBuf bytes.Buffer + err := goTracer.parseJSONOutput(strings.NewReader(jsonOutput), &outputBuf) + if err != nil { + t.Fatalf("parseJSONOutput failed: %v", err) + } +} + +func TestEnsureJSONFlag(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "already has -json", + input: []string{"go", "test", "-json", "./..."}, + expected: []string{"go", "test", "-json", "./..."}, + }, + { + name: "needs -json after test", + input: []string{"go", "test", "-v", "./..."}, + expected: []string{"go", "test", "-json", "-v", "./..."}, + }, + { + name: "simple test command", + input: []string{"go", "test"}, + expected: []string{"go", "test", "-json"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ensureJSONFlag(tt.input) + + // Check that -json is present + hasJSON := false + for _, arg := range result { + if arg == "-json" { + hasJSON = true + break + } + } + if !hasJSON { + t.Errorf("result %v does not contain -json", result) + } + }) + } +} + +func TestSpanKey(t *testing.T) { + tests := []struct { + pkg string + test string + expected string + }{ + {"example.com/pkg", "TestOne", "example.com/pkg/TestOne"}, + {"example.com/pkg", "", "example.com/pkg"}, + {"pkg", "TestSub/case1", "pkg/TestSub/case1"}, + } + + for _, tt := range tests { + result := spanKey(tt.pkg, tt.test) + if result != tt.expected { + t.Errorf("spanKey(%q, %q) = %q, want %q", tt.pkg, tt.test, result, tt.expected) + } + } +} + +func TestFormatTestSpanName(t *testing.T) { + tests := []struct { + pkg string + test string + expected string + }{ + {"example.com/pkg", "TestOne", "test: pkg/TestOne"}, + {"github.com/org/repo/internal/service", "TestCreate", "test: service/TestCreate"}, + {"simple", "TestSimple", "test: simple/TestSimple"}, + } + + for _, tt := range tests { + result := formatTestSpanName(tt.pkg, tt.test) + if result != tt.expected { + t.Errorf("formatTestSpanName(%q, %q) = %q, want %q", tt.pkg, tt.test, result, tt.expected) + } + } +} + +func TestGoTestEvent_Parsing(t *testing.T) { + // Test that goTestEvent can parse real go test -json output + jsonLine := `{"Time":"2024-01-15T10:30:45.123456789Z","Action":"pass","Package":"github.com/example/pkg","Test":"TestExample","Elapsed":1.234}` + + var event goTestEvent + err := json.Unmarshal([]byte(jsonLine), &event) + if err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if event.Action != "pass" { + t.Errorf("expected action 'pass', got %q", event.Action) + } + if event.Package != "github.com/example/pkg" { + t.Errorf("expected package 'github.com/example/pkg', got %q", event.Package) + } + if event.Test != "TestExample" { + t.Errorf("expected test 'TestExample', got %q", event.Test) + } + if event.Elapsed != 1.234 { + t.Errorf("expected elapsed 1.234, got %f", event.Elapsed) + } + if event.Time.IsZero() { + t.Error("expected non-zero time") + } +} + +func TestFindOTelReporter(t *testing.T) { + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider( + sdktrace.WithSyncer(exporter), + ) + defer func() { _ = tp.Shutdown(context.Background()) }() + + tracer := tp.Tracer("test") + otelReporter := NewOTelReporter(tracer, context.Background()) + + // Test finding OTelReporter directly + found, ok := findOTelReporter(otelReporter) + if !ok || found != otelReporter { + t.Error("expected to find OTelReporter directly") + } + + // Test finding OTelReporter in CompositeReporter + composite := CompositeReporter{NewConsoleReporter(), otelReporter} + found, ok = findOTelReporter(composite) + if !ok || found != otelReporter { + t.Error("expected to find OTelReporter in CompositeReporter") + } + + // Test not finding OTelReporter + compositeNoOtel := CompositeReporter{NewConsoleReporter()} + _, ok = findOTelReporter(compositeNoOtel) + if ok { + t.Error("expected not to find OTelReporter when none present") + } +} + +func TestIsGoTestCommand(t *testing.T) { + tests := []struct { + name string + cmdName string + args []string + expected bool + }{ + {"go test", "go", []string{"test", "./..."}, true}, + {"go test with flags", "go", []string{"test", "-v", "-json", "./..."}, true}, + {"go build", "go", []string{"build", "."}, false}, + {"not go", "make", []string{"test"}, false}, + {"go without subcommand", "go", []string{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isGoTestCommand(tt.cmdName, tt.args) + if result != tt.expected { + t.Errorf("isGoTestCommand(%q, %v) = %v, want %v", tt.cmdName, tt.args, result, tt.expected) + } + }) + } +} diff --git a/pkg/leeway/reporter.go b/pkg/leeway/reporter.go index fe9ab18..b60457c 100644 --- a/pkg/leeway/reporter.go +++ b/pkg/leeway/reporter.go @@ -941,4 +941,30 @@ func (r *OTelReporter) addGitHubAttributes(span trace.Span) { } } +// GetPackageContext returns the tracing context for a package build. +// This can be used to create child spans for operations within the package build. +// Returns nil if no context is available for the package. +func (r *OTelReporter) GetPackageContext(pkg *Package) context.Context { + if r.tracer == nil { + return nil + } + + r.mu.RLock() + defer r.mu.RUnlock() + + pkgName := pkg.FullName() + ctx, ok := r.packageCtxs[pkgName] + if !ok { + return nil + } + + return ctx +} + +// GetTracer returns the OpenTelemetry tracer used by this reporter. +// Returns nil if tracing is not configured. +func (r *OTelReporter) GetTracer() trace.Tracer { + return r.tracer +} + var _ Reporter = (*OTelReporter)(nil)