diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 28db4c06..4634d8dd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -10,6 +10,11 @@ "ghcr.io/devcontainers/features/go:1": { "version": "1.24" }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "latest", + "moby": true, + "dockerDashComposeVersion": "v2" + }, "ghcr.io/devcontainers/features/common-utils:2": {}, "ghcr.io/dhoeric/features/google-cloud-cli:1": {}, "ghcr.io/devcontainers/features/aws-cli:1": {} diff --git a/README.md b/README.md index b7b11b7b..a8759082 100644 --- a/README.md +++ b/README.md @@ -593,6 +593,45 @@ variables have an effect on leeway: - `LEEWAY_YARN_MUTEX`: Configures the mutex flag leeway will pass to yarn. Defaults to "network". See https://yarnpkg.com/lang/en/docs/cli/#toc-concurrency-and-mutex for possible values. - `LEEWAY_EXPERIMENTAL`: Enables exprimental features +# OpenTelemetry Tracing + +Leeway supports distributed tracing using OpenTelemetry for build performance visibility. + +## Quick Start + +```bash +# Local development (Jaeger) +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318 +export OTEL_EXPORTER_OTLP_INSECURE=true +leeway build :my-package + +# Production (Honeycomb with API key) +export OTEL_EXPORTER_OTLP_ENDPOINT=api.honeycomb.io:443 +export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY" +leeway build :my-package +``` + +The OpenTelemetry SDK automatically reads standard `OTEL_EXPORTER_OTLP_*` environment variables. + +## Span Hierarchy + +Leeway creates a nested span hierarchy for detailed build timeline visualization: + +``` +leeway.build (root) +├── leeway.package (component:package-1) +│ ├── leeway.phase (prep) +│ ├── leeway.phase (build) +│ └── leeway.phase (test) +└── leeway.package (component:package-2) + ├── leeway.phase (prep) + └── leeway.phase (build) +``` + +Each phase span captures timing, status, and errors for individual build phases (prep, pull, lint, test, build, package). + +**For detailed configuration, examples, and span attributes, see [docs/observability.md](docs/observability.md).** + # Provenance (SLSA) - EXPERIMENTAL leeway can produce provenance information as part of a build. At the moment only [SLSA Provenance v0.2](https://slsa.dev/provenance/v0.2) is supported. This support is **experimental**. diff --git a/cmd/build.go b/cmd/build.go index 81627a71..20426cdf 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -15,9 +15,12 @@ import ( "github.com/gitpod-io/leeway/pkg/leeway/cache" "github.com/gitpod-io/leeway/pkg/leeway/cache/local" "github.com/gitpod-io/leeway/pkg/leeway/cache/remote" + "github.com/gitpod-io/leeway/pkg/leeway/telemetry" "github.com/gookit/color" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" ) // buildCmd represents the build command @@ -47,7 +50,8 @@ Examples: if pkg == nil { log.Fatal("build needs a package") } - opts, localCache := getBuildOpts(cmd) + opts, localCache, shutdown := getBuildOpts(cmd) + defer shutdown() var ( watch, _ = cmd.Flags().GetBool("watch") @@ -209,9 +213,13 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().Bool("report-github", os.Getenv("GITHUB_OUTPUT") != "", "Report package build success/failure to GitHub Actions using the GITHUB_OUTPUT environment variable") cmd.Flags().Bool("fixed-build-dir", true, "Use a fixed build directory for each package, instead of based on the package version, to better utilize caches based on absolute paths (defaults to true)") cmd.Flags().Bool("docker-export-to-cache", false, "Export Docker images to cache instead of pushing directly (enables SLSA L3 compliance)") + cmd.Flags().String("otel-endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "OpenTelemetry OTLP endpoint URL for tracing (defaults to $OTEL_EXPORTER_OTLP_ENDPOINT)") + cmd.Flags().Bool("otel-insecure", os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") == "true", "Disable TLS for OTLP endpoint (for local development only, defaults to $OTEL_EXPORTER_OTLP_INSECURE)") + cmd.Flags().String("trace-parent", os.Getenv("TRACEPARENT"), "W3C Trace Context traceparent header for distributed tracing (defaults to $TRACEPARENT)") + cmd.Flags().String("trace-state", os.Getenv("TRACESTATE"), "W3C Trace Context tracestate header for distributed tracing (defaults to $TRACESTATE)") } -func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { +func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache, func()) { // Track if user explicitly set LEEWAY_DOCKER_EXPORT_TO_CACHE before workspace loading. // This allows us to distinguish: // - User set explicitly: High priority (overrides package config) @@ -312,6 +320,61 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { reporter = append(reporter, leeway.NewGitHubReporter()) } + // Initialize OpenTelemetry reporter if endpoint is configured + var tracerProvider *sdktrace.TracerProvider + var otelShutdown func() + if otelEndpoint, err := cmd.Flags().GetString("otel-endpoint"); err != nil { + log.Fatal(err) + } else if otelEndpoint != "" { + // Set leeway version for telemetry + telemetry.SetLeewayVersion(leeway.Version) + + // Get insecure flag + otelInsecure, err := cmd.Flags().GetBool("otel-insecure") + if err != nil { + log.Fatal(err) + } + + // Initialize tracer with the provided endpoint and TLS configuration + tp, err := telemetry.InitTracer(context.Background(), otelEndpoint, otelInsecure) + if err != nil { + log.WithError(err).Warn("failed to initialize OpenTelemetry tracer") + } else { + tracerProvider = tp + + // Parse trace context if provided + traceParent, _ := cmd.Flags().GetString("trace-parent") + traceState, _ := cmd.Flags().GetString("trace-state") + + parentCtx := context.Background() + if traceParent != "" { + if err := telemetry.ValidateTraceParent(traceParent); err != nil { + log.WithError(err).Warn("invalid trace-parent format") + } else { + ctx, err := telemetry.ParseTraceContext(traceParent, traceState) + if err != nil { + log.WithError(err).Warn("failed to parse trace context") + } else { + parentCtx = ctx + } + } + } + + // Create OTel reporter + tracer := otel.Tracer("leeway") + reporter = append(reporter, leeway.NewOTelReporter(tracer, parentCtx)) + + // Create shutdown function + otelShutdown = func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := telemetry.Shutdown(shutdownCtx, tracerProvider); err != nil { + log.WithError(err).Warn("failed to shutdown tracer provider") + } + } + } + } + dontTest, err := cmd.Flags().GetBool("dont-test") if err != nil { log.Fatal(err) @@ -374,6 +437,11 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { dockerExportSet = true } + // Create a no-op shutdown function if otelShutdown is nil + if otelShutdown == nil { + otelShutdown = func() {} + } + return []leeway.BuildOption{ leeway.WithLocalCache(localCache), leeway.WithRemoteCache(remoteCache), @@ -391,7 +459,7 @@ func getBuildOpts(cmd *cobra.Command) ([]leeway.BuildOption, cache.LocalCache) { leeway.WithInFlightChecksums(inFlightChecksums), leeway.WithDockerExportToCache(dockerExportToCache, dockerExportSet), leeway.WithDockerExportEnv(dockerExportEnvValue, dockerExportEnvSet), - }, localCache + }, localCache, otelShutdown } type pushOnlyRemoteCache struct { @@ -506,6 +574,7 @@ func getRemoteCache(cmd *cobra.Command) cache.RemoteCache { SLSA: slsaConfig, } + switch remoteStorage { case "GCP": if slsaConfig != nil && slsaConfig.Verification { diff --git a/cmd/build_test.go b/cmd/build_test.go index 9ea903b3..7a1c0213 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -242,7 +242,7 @@ func TestGetBuildOptsWithInFlightChecksums(t *testing.T) { } // Test getBuildOpts function - opts, localCache := getBuildOpts(cmd) + opts, localCache, _ := getBuildOpts(cmd) // We can't directly test the WithInFlightChecksums option since it's internal, // but we can verify the function doesn't error and returns options diff --git a/cmd/provenance-assert.go b/cmd/provenance-assert.go index 0be96f36..2a0e7ef4 100644 --- a/cmd/provenance-assert.go +++ b/cmd/provenance-assert.go @@ -125,7 +125,7 @@ func getProvenanceTarget(cmd *cobra.Command, args []string) (bundleFN, pkgFN str log.Fatal("provenance export requires a package") } - _, cache := getBuildOpts(cmd) + _, cache, _ := getBuildOpts(cmd) var ok bool pkgFN, ok = cache.Location(pkg) diff --git a/cmd/run.go b/cmd/run.go index 4a08d6c6..d673f1b3 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -27,7 +27,7 @@ Should any of the scripts fail Leeway will exit with an exit code of 1 once all if script == nil { return errors.New("run needs a script") } - opts, _ := getBuildOpts(cmd) + opts, _, _ := getBuildOpts(cmd) return script.Run(opts...) }) } diff --git a/cmd/sbom-export.go b/cmd/sbom-export.go index 7035c4d4..37cced6d 100644 --- a/cmd/sbom-export.go +++ b/cmd/sbom-export.go @@ -32,7 +32,7 @@ If no package is specified, the workspace's default target is used.`, } // Get build options and cache - _, localCache := getBuildOpts(cmd) + _, localCache, _ := getBuildOpts(cmd) // Get output format and file format, _ := cmd.Flags().GetString("format") diff --git a/cmd/sbom-scan.go b/cmd/sbom-scan.go index bbb5591c..f2531278 100644 --- a/cmd/sbom-scan.go +++ b/cmd/sbom-scan.go @@ -30,7 +30,7 @@ If no package is specified, the workspace's default target is used.`, } // Get cache - _, localCache := getBuildOpts(cmd) + _, localCache, _ := getBuildOpts(cmd) // Get output directory outputDir, _ := cmd.Flags().GetString("output-dir") diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 00000000..29561488 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,327 @@ +# Observability + +Leeway supports distributed tracing using OpenTelemetry to provide visibility into build performance and behavior. + +## Overview + +OpenTelemetry tracing in leeway captures: +- Build lifecycle (start to finish) +- Individual package builds +- Build phase durations (prep, pull, lint, test, build, package) +- Cache hit/miss information +- GitHub Actions context (when running in CI) +- Parent trace context propagation from CI systems + +## Architecture + +### Span Hierarchy + +``` +Root Span (leeway.build) +├── Package Span 1 (leeway.package) +│ ├── Phase Span (leeway.phase: prep) +│ ├── Phase Span (leeway.phase: pull) +│ ├── Phase Span (leeway.phase: lint) +│ ├── Phase Span (leeway.phase: test) +│ ├── Phase Span (leeway.phase: build) +│ └── Phase Span (leeway.phase: package) +├── Package Span 2 (leeway.package) +│ ├── Phase Span (leeway.phase: prep) +│ └── Phase Span (leeway.phase: build) +└── Package Span N (leeway.package) + └── ... +``` + +- **Root Span**: Created when `BuildStarted` is called, represents the entire build operation +- **Package Spans**: Created for each package being built, as children of the root span +- **Phase Spans**: Created for each build phase (prep, pull, lint, test, build, package) as children of package spans + +Phase spans provide detailed timeline visualization and capture individual phase errors. Only phases with commands are executed and create spans. + +### Context Propagation + +Leeway supports W3C Trace Context propagation, allowing builds to be part of larger distributed traces: + +1. **Parent Context**: Accepts `traceparent` and `tracestate` headers from upstream systems +2. **Root Context**: Creates a root span linked to the parent context +3. **Package Context**: Each package span is a child of the root span +4. **Phase Context**: Each phase span is a child of its package span + +## Configuration + +### Environment Variables + +- `OTEL_EXPORTER_OTLP_ENDPOINT`: OTLP endpoint URL (e.g., `localhost:4318` or `api.honeycomb.io:443`) +- `OTEL_EXPORTER_OTLP_INSECURE`: Disable TLS for OTLP endpoint (`true` or `false`, default: `false`) +- `OTEL_EXPORTER_OTLP_HEADERS`: HTTP headers for OTLP requests (e.g., `x-honeycomb-team=YOUR_API_KEY`) +- `OTEL_EXPORTER_OTLP_TRACES_HEADERS`: Trace-specific headers (takes precedence over `OTEL_EXPORTER_OTLP_HEADERS`) +- `TRACEPARENT`: W3C Trace Context traceparent header (format: `00-{trace-id}-{span-id}-{flags}`) +- `TRACESTATE`: W3C Trace Context tracestate header (optional) + +**Note:** The OpenTelemetry SDK automatically reads `OTEL_EXPORTER_OTLP_HEADERS` and `OTEL_EXPORTER_OTLP_TRACES_HEADERS` from the environment. No additional configuration is required. + +### CLI Flags + +- `--otel-endpoint`: OTLP endpoint URL (overrides `OTEL_EXPORTER_OTLP_ENDPOINT`) +- `--otel-insecure`: Disable TLS for OTLP endpoint (overrides `OTEL_EXPORTER_OTLP_INSECURE`) +- `--trace-parent`: W3C traceparent header (overrides `TRACEPARENT`) +- `--trace-state`: W3C tracestate header (overrides `TRACESTATE`) + +### Precedence + +CLI flags take precedence over environment variables: +``` +CLI flag → Environment variable → Default (disabled) +``` + +### TLS Configuration + +By default, leeway uses **secure TLS connections** to the OTLP endpoint. For local development with tools like Jaeger, you can disable TLS: + +```bash +# Local development (insecure) +export OTEL_EXPORTER_OTLP_INSECURE=true +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318 +leeway build :my-package + +# Production (secure, default) +export OTEL_EXPORTER_OTLP_ENDPOINT=api.honeycomb.io:443 +export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY" +leeway build :my-package +``` + +## Span Attributes + +### Root Span Attributes + +| Attribute | Type | Description | Example | +|-----------|------|-------------|---------| +| `leeway.version` | string | Leeway version | `"0.7.0"` | +| `leeway.workspace.root` | string | Workspace root path | `"/workspace"` | +| `leeway.target.package` | string | Target package being built | `"components/server:app"` | +| `leeway.target.version` | string | Target package version | `"abc123def"` | +| `leeway.packages.total` | int | Total packages in build | `42` | +| `leeway.packages.cached` | int | Packages cached locally | `35` | +| `leeway.packages.remote` | int | Packages in remote cache | `5` | +| `leeway.packages.downloaded` | int | Packages downloaded | `3` | +| `leeway.packages.to_build` | int | Packages to build | `2` | + +### Package Span Attributes + +| Attribute | Type | Description | Example | +|-----------|------|-------------|---------| +| `leeway.package.name` | string | Package full name | `"components/server:app"` | +| `leeway.package.type` | string | Package type | `"go"`, `"yarn"`, `"docker"`, `"generic"` | +| `leeway.package.version` | string | Package version | `"abc123def"` | +| `leeway.package.builddir` | string | Build directory | `"/tmp/leeway/build/..."` | +| `leeway.package.last_phase` | string | Last completed phase | `"build"` | +| `leeway.package.duration_ms` | int64 | Total build duration (ms) | `15234` | +| `leeway.package.test.coverage_percentage` | int | Test coverage % | `85` | +| `leeway.package.test.functions_with_test` | int | Functions with tests | `42` | +| `leeway.package.test.functions_without_test` | int | Functions without tests | `8` | + +### Phase Span Attributes + +Phase spans are created for each build phase (prep, pull, lint, test, build, package) that has commands to execute. + +| Attribute | Type | Description | Example | +|-----------|------|-------------|---------| +| `leeway.phase.name` | string | Phase name | `"prep"`, `"build"`, `"test"`, etc. | + +**Span Status:** +- `OK`: Phase completed successfully +- `ERROR`: Phase failed (error details in span events) + +**Span Duration:** The span's start and end times capture the phase execution duration automatically. + +### GitHub Actions Attributes + +When running in GitHub Actions (`GITHUB_ACTIONS=true`), the following attributes are added to the root span: + +| Attribute | Environment Variable | Description | +|-----------|---------------------|-------------| +| `github.workflow` | `GITHUB_WORKFLOW` | Workflow name | +| `github.run_id` | `GITHUB_RUN_ID` | Unique run identifier | +| `github.run_number` | `GITHUB_RUN_NUMBER` | Run number | +| `github.job` | `GITHUB_JOB` | Job name | +| `github.actor` | `GITHUB_ACTOR` | User who triggered the workflow | +| `github.repository` | `GITHUB_REPOSITORY` | Repository name | +| `github.ref` | `GITHUB_REF` | Git ref | +| `github.sha` | `GITHUB_SHA` | Commit SHA | +| `github.server_url` | `GITHUB_SERVER_URL` | GitHub server URL | +| `github.workflow_ref` | `GITHUB_WORKFLOW_REF` | Workflow reference | + +## Usage Examples + +### Basic Usage + +```bash +# Set OTLP endpoint +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318 + +# Build with tracing enabled +leeway build :my-package +``` + +### With CLI Flags + +```bash +leeway build :my-package \ + --otel-endpoint=localhost:4318 +``` + +### With Parent Trace Context + +```bash +# Propagate trace context from CI system +leeway build :my-package \ + --otel-endpoint=localhost:4318 \ + --trace-parent="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" +``` + +### In GitHub Actions + +```yaml +name: Build +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build with tracing + env: + OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_ENDPOINT }} + run: | + leeway build :my-package +``` + +### With Jaeger (Local Development) + +```bash +# Start Jaeger all-in-one +docker run -d --name jaeger \ + -p 4318:4318 \ + -p 16686:16686 \ + jaegertracing/all-in-one:latest + +# Build with tracing (insecure for local development) +export OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4318 +export OTEL_EXPORTER_OTLP_INSECURE=true +leeway build :my-package + +# View traces at http://localhost:16686 +``` + +### With Honeycomb (Production) + +```bash +# Configure Honeycomb endpoint with API key +export OTEL_EXPORTER_OTLP_ENDPOINT=api.honeycomb.io:443 +export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY" + +# Build with tracing (secure by default) +leeway build :my-package + +# View traces in Honeycomb UI +``` + +### In CI/CD with Distributed Tracing + +```bash +# Propagate trace context from parent CI system +export OTEL_EXPORTER_OTLP_ENDPOINT=api.honeycomb.io:443 +export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY" +export TRACEPARENT="00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" + +leeway build :my-package +``` + +## Error Handling + +Leeway implements graceful degradation for tracing: + +- **Tracer initialization failures**: Logged as warnings, build continues without tracing +- **Span creation failures**: Logged as warnings, build continues +- **OTLP endpoint unavailable**: Spans are buffered and flushed on shutdown (with timeout) +- **Invalid trace context**: Logged as warning, new trace is started + +Tracing failures never cause build failures. + +## Performance Considerations + +- **Overhead**: Minimal (<1% in typical builds) +- **Concurrent builds**: Thread-safe with RWMutex protection +- **Shutdown timeout**: 5 seconds to flush pending spans +- **Batch export**: Spans are batched for efficient export + +## Troubleshooting + +### No spans appearing in backend + +1. Verify OTLP endpoint is reachable: + ```bash + curl -v http://localhost:4318/v1/traces + ``` + +2. Check leeway logs for warnings: + ```bash + leeway build :package 2>&1 | grep -i otel + ``` + +3. Verify environment variables: + ```bash + echo $OTEL_EXPORTER_OTLP_ENDPOINT + ``` + +### Invalid trace context errors + +Validate traceparent format: +``` +Format: 00-{32-hex-trace-id}-{16-hex-span-id}-{2-hex-flags} +Example: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 +``` + +### Spans not linked to parent + +Ensure both `traceparent` and `tracestate` (if present) are provided: +```bash +leeway build :package \ + --trace-parent="00-..." \ + --trace-state="..." +``` + +## Implementation Details + +### Thread Safety + +- Single `sync.RWMutex` protects `packageCtxs` and `packageSpans` maps +- Safe for concurrent package builds +- Read locks for lookups, write locks for modifications + +### Shutdown + +- Automatic shutdown with 5-second timeout +- Registered as deferred function in `getBuildOpts` +- Ensures all spans are flushed before exit + +### Testing + +Tests use in-memory exporters (`tracetest.NewInMemoryExporter()`) to verify: +- Span creation and hierarchy +- Attribute correctness +- Concurrent package builds +- Parent context propagation +- Graceful degradation with nil tracer + +## Future Enhancements + +Potential improvements for future iterations: + +- **Phase-level spans**: Create individual spans for each build phase (prep, pull, lint, test, build, package) instead of just attributes +- **Span events**: Add timeline events for build milestones (e.g., cache hit, dependency resolution) +- **Metrics integration**: Export metrics alongside traces (build duration histograms, cache hit rates, concurrent build count) +- **Sampling configuration**: Add configurable sampling strategies for high-volume builds +- **Additional exporters**: Support for Zipkin, Jaeger native protocol, or Prometheus diff --git a/go.mod b/go.mod index f716f826..b24fa738 100644 --- a/go.mod +++ b/go.mod @@ -32,6 +32,10 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 golang.org/x/mod v0.29.0 golang.org/x/sync v0.18.0 golang.org/x/time v0.13.0 @@ -111,6 +115,7 @@ require ( github.com/bmatcuk/doublestar/v2 v2.0.4 // indirect github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -337,11 +342,10 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.38.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/pkg/leeway/build.go b/pkg/leeway/build.go index 3d00a265..faf778a0 100644 --- a/pkg/leeway/build.go +++ b/pkg/leeway/build.go @@ -1187,6 +1187,11 @@ func executeBuildPhase(buildctx *buildContext, p *Package, builddir string, bld return nil } + // Notify phase-aware reporters + if par, ok := buildctx.Reporter.(PhaseAwareReporter); ok { + par.PackageBuildPhaseStarted(p, phase) + } + if phase != PackageBuildPhasePrep { pkgRep.phaseEnter[phase] = time.Now() pkgRep.Phases = append(pkgRep.Phases, phase) @@ -1197,6 +1202,11 @@ func executeBuildPhase(buildctx *buildContext, p *Package, builddir string, bld err := executeCommandsForPackage(buildctx, p, builddir, cmds) pkgRep.phaseDone[phase] = time.Now() + // Notify phase-aware reporters + if par, ok := buildctx.Reporter.(PhaseAwareReporter); ok { + par.PackageBuildPhaseFinished(p, phase, err) + } + return err } diff --git a/pkg/leeway/reporter.go b/pkg/leeway/reporter.go index 3afc80bd..a4ac6f0c 100644 --- a/pkg/leeway/reporter.go +++ b/pkg/leeway/reporter.go @@ -1,6 +1,7 @@ package leeway import ( + "context" "encoding/json" "fmt" "io" @@ -19,6 +20,9 @@ import ( "github.com/gookit/color" segment "github.com/segmentio/analytics-go/v3" "github.com/segmentio/textio" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" ) // Reporter provides feedback about the build progress to the user. @@ -36,7 +40,7 @@ type Reporter interface { // The root package will also be passed into PackageBuildFinished once it's been built. BuildFinished(pkg *Package, err error) - // PackageBuildStarted is called when a package build actually gets underway. At this point + // PackageBuildStarted is called when a package build actually gets underway. At this point // all transitive dependencies of the package have been built. PackageBuildStarted(pkg *Package, builddir string) @@ -48,6 +52,17 @@ type Reporter interface { PackageBuildFinished(pkg *Package, rep *PackageBuildReport) } +// PhaseAwareReporter is an optional interface that reporters can implement +// to receive phase-level notifications for creating nested spans or tracking. +// This follows the Go pattern of optional interfaces (like io.Closer, io.Seeker). +type PhaseAwareReporter interface { + Reporter + // PackageBuildPhaseStarted is called when a build phase starts + PackageBuildPhaseStarted(pkg *Package, phase PackageBuildPhase) + // PackageBuildPhaseFinished is called when a build phase completes + PackageBuildPhaseFinished(pkg *Package, phase PackageBuildPhase, err error) +} + type PackageBuildReport struct { phaseEnter map[PackageBuildPhase]time.Time phaseDone map[PackageBuildPhase]time.Time @@ -686,3 +701,311 @@ func (sr *GitHubActionReporter) PackageBuildFinished(pkg *Package, rep *PackageB } fmt.Fprintf(f, "%s=%v\n", pkg.FilesystemSafeName(), success) } + +// OTelReporter reports build progress using OpenTelemetry tracing +type OTelReporter struct { + NoopReporter + + tracer trace.Tracer + parentCtx context.Context + rootCtx context.Context + rootSpan trace.Span + packageCtxs map[string]context.Context + packageSpans map[string]trace.Span + phaseSpans map[string]trace.Span // key: "packageName:phaseName" + mu sync.RWMutex +} + +// NewOTelReporter creates a new OpenTelemetry reporter with the given tracer and parent context +func NewOTelReporter(tracer trace.Tracer, parentCtx context.Context) *OTelReporter { + if parentCtx == nil { + parentCtx = context.Background() + } + return &OTelReporter{ + tracer: tracer, + parentCtx: parentCtx, + packageCtxs: make(map[string]context.Context), + packageSpans: make(map[string]trace.Span), + phaseSpans: make(map[string]trace.Span), + } +} + +// BuildStarted implements Reporter +func (r *OTelReporter) BuildStarted(pkg *Package, status map[*Package]PackageBuildStatus) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Create root span for the build + ctx, span := r.tracer.Start(r.parentCtx, "leeway.build", + trace.WithSpanKind(trace.SpanKindInternal), + ) + r.rootCtx = ctx + r.rootSpan = span + + // Add root span attributes + version, err := pkg.Version() + if err != nil { + version = "unknown" + } + + span.SetAttributes( + attribute.String("leeway.version", Version), + attribute.String("leeway.workspace.root", pkg.C.W.Origin), + attribute.String("leeway.target.package", pkg.FullName()), + attribute.String("leeway.target.version", version), + ) + + // Add GitHub context attributes if available + r.addGitHubAttributes(span) + + // Add build status summary + var ( + cached int + remote int + download int + toBuild int + ) + for _, s := range status { + switch s { + case PackageBuilt: + cached++ + case PackageInRemoteCache: + remote++ + case PackageDownloaded: + download++ + case PackageNotBuiltYet: + toBuild++ + } + } + + span.SetAttributes( + attribute.Int("leeway.packages.total", len(status)), + attribute.Int("leeway.packages.cached", cached), + attribute.Int("leeway.packages.remote", remote), + attribute.Int("leeway.packages.downloaded", download), + attribute.Int("leeway.packages.to_build", toBuild), + ) +} + +// BuildFinished implements Reporter +func (r *OTelReporter) BuildFinished(pkg *Package, err error) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + if r.rootSpan == nil { + return + } + + // Set error status if build failed + if err != nil { + r.rootSpan.RecordError(err) + r.rootSpan.SetStatus(codes.Error, err.Error()) + } else { + r.rootSpan.SetStatus(codes.Ok, "build completed successfully") + } + + // End root span + r.rootSpan.End() + r.rootSpan = nil + r.rootCtx = nil +} + +// PackageBuildStarted implements Reporter +func (r *OTelReporter) PackageBuildStarted(pkg *Package, builddir string) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + if r.rootCtx == nil { + log.Warn("PackageBuildStarted called before BuildStarted") + return + } + + pkgName := pkg.FullName() + + // Create package span as child of root span + ctx, span := r.tracer.Start(r.rootCtx, "leeway.package", + trace.WithSpanKind(trace.SpanKindInternal), + ) + + // Add package attributes + version, err := pkg.Version() + if err != nil { + version = "unknown" + } + + span.SetAttributes( + attribute.String("leeway.package.name", pkgName), + attribute.String("leeway.package.type", string(pkg.Type)), + attribute.String("leeway.package.version", version), + attribute.String("leeway.package.builddir", builddir), + ) + + // Store context and span + r.packageCtxs[pkgName] = ctx + r.packageSpans[pkgName] = span +} + +// PackageBuildFinished implements Reporter +func (r *OTelReporter) PackageBuildFinished(pkg *Package, rep *PackageBuildReport) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + pkgName := pkg.FullName() + span, ok := r.packageSpans[pkgName] + if !ok { + log.WithField("package", pkgName).Warn("PackageBuildFinished called without corresponding PackageBuildStarted") + return + } + + // Add build report attributes + span.SetAttributes( + attribute.String("leeway.package.last_phase", string(rep.LastPhase())), + attribute.Int64("leeway.package.duration_ms", rep.TotalTime().Milliseconds()), + ) + + // Add test coverage if available + if rep.TestCoverageAvailable { + span.SetAttributes( + attribute.Int("leeway.package.test.coverage_percentage", rep.TestCoveragePercentage), + attribute.Int("leeway.package.test.functions_with_test", rep.FunctionsWithTest), + attribute.Int("leeway.package.test.functions_without_test", rep.FunctionsWithoutTest), + ) + } + + // Set error status if build failed + if rep.Error != nil { + span.RecordError(rep.Error) + span.SetStatus(codes.Error, rep.Error.Error()) + } else { + span.SetStatus(codes.Ok, "package built successfully") + } + + // End span + span.End() + + // Clean up + delete(r.packageSpans, pkgName) + delete(r.packageCtxs, pkgName) +} + +// PackageBuildPhaseStarted implements PhaseAwareReporter +func (r *OTelReporter) PackageBuildPhaseStarted(pkg *Package, phase PackageBuildPhase) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + pkgName := pkg.FullName() + packageCtx, ok := r.packageCtxs[pkgName] + if !ok { + log.WithField("package", pkgName).Warn("PackageBuildPhaseStarted called without package context") + return + } + + // Create phase span as child of package span + phaseKey := fmt.Sprintf("%s:%s", pkgName, phase) + ctx, span := r.tracer.Start(packageCtx, "leeway.phase", + trace.WithSpanKind(trace.SpanKindInternal), + ) + + // Add phase attributes + span.SetAttributes( + attribute.String("leeway.phase.name", string(phase)), + ) + + // Store phase span and update package context + r.phaseSpans[phaseKey] = span + r.packageCtxs[pkgName] = ctx +} + +// PackageBuildPhaseFinished implements PhaseAwareReporter +func (r *OTelReporter) PackageBuildPhaseFinished(pkg *Package, phase PackageBuildPhase, err error) { + if r.tracer == nil { + return + } + + r.mu.Lock() + defer r.mu.Unlock() + + pkgName := pkg.FullName() + phaseKey := fmt.Sprintf("%s:%s", pkgName, phase) + span, ok := r.phaseSpans[phaseKey] + if !ok { + log.WithField("package", pkgName).WithField("phase", phase).Warn("PackageBuildPhaseFinished called without corresponding PackageBuildPhaseStarted") + return + } + + // Set error status if phase failed + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } else { + span.SetStatus(codes.Ok, "phase completed successfully") + } + + // End span + span.End() + + // Clean up + delete(r.phaseSpans, phaseKey) +} + +// addGitHubAttributes adds GitHub Actions context attributes to the span +func (r *OTelReporter) addGitHubAttributes(span trace.Span) { + // Check if running in GitHub Actions + if os.Getenv("GITHUB_ACTIONS") != "true" { + return + } + + // Add GitHub context attributes + if val := os.Getenv("GITHUB_WORKFLOW"); val != "" { + span.SetAttributes(attribute.String("github.workflow", val)) + } + if val := os.Getenv("GITHUB_RUN_ID"); val != "" { + span.SetAttributes(attribute.String("github.run_id", val)) + } + if val := os.Getenv("GITHUB_RUN_NUMBER"); val != "" { + span.SetAttributes(attribute.String("github.run_number", val)) + } + if val := os.Getenv("GITHUB_JOB"); val != "" { + span.SetAttributes(attribute.String("github.job", val)) + } + if val := os.Getenv("GITHUB_ACTOR"); val != "" { + span.SetAttributes(attribute.String("github.actor", val)) + } + if val := os.Getenv("GITHUB_REPOSITORY"); val != "" { + span.SetAttributes(attribute.String("github.repository", val)) + } + if val := os.Getenv("GITHUB_REF"); val != "" { + span.SetAttributes(attribute.String("github.ref", val)) + } + if val := os.Getenv("GITHUB_SHA"); val != "" { + span.SetAttributes(attribute.String("github.sha", val)) + } + if val := os.Getenv("GITHUB_SERVER_URL"); val != "" { + span.SetAttributes(attribute.String("github.server_url", val)) + } + if val := os.Getenv("GITHUB_WORKFLOW_REF"); val != "" { + span.SetAttributes(attribute.String("github.workflow_ref", val)) + } +} + +var _ Reporter = (*OTelReporter)(nil) diff --git a/pkg/leeway/reporter_otel_phase_test.go b/pkg/leeway/reporter_otel_phase_test.go new file mode 100644 index 00000000..8b68c396 --- /dev/null +++ b/pkg/leeway/reporter_otel_phase_test.go @@ -0,0 +1,329 @@ +package leeway + +import ( + "context" + "fmt" + "testing" + "time" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestOTelReporter_PhaseSpans(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Start build and package + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Simulate phase execution + phases := []PackageBuildPhase{ + PackageBuildPhasePrep, + PackageBuildPhaseBuild, + PackageBuildPhaseTest, + } + + for _, phase := range phases { + reporter.PackageBuildPhaseStarted(pkg, phase) + time.Sleep(10 * time.Millisecond) // Simulate work + reporter.PackageBuildPhaseFinished(pkg, phase, nil) + } + + // Finish package and build + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: phases, + Error: nil, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) < 5 { // build + package + 3 phases + t.Fatalf("Expected at least 5 spans (build + package + 3 phases), got %d", len(spans)) + } + + // Count phase spans + phaseSpanCount := 0 + for _, span := range spans { + if span.Name == "leeway.phase" { + phaseSpanCount++ + + // Verify phase has name attribute + hasPhaseNameAttr := false + for _, attr := range span.Attributes { + if string(attr.Key) == "leeway.phase.name" { + hasPhaseNameAttr = true + phaseName := attr.Value.AsString() + if phaseName != string(PackageBuildPhasePrep) && + phaseName != string(PackageBuildPhaseBuild) && + phaseName != string(PackageBuildPhaseTest) { + t.Errorf("Unexpected phase name: %s", phaseName) + } + } + } + if !hasPhaseNameAttr { + t.Error("Expected 'leeway.phase.name' attribute in phase span") + } + + // Verify status is OK + if span.Status.Code != codes.Ok { + t.Errorf("Expected phase span status OK, got %v", span.Status.Code) + } + } + } + + if phaseSpanCount != 3 { + t.Errorf("Expected 3 phase spans, got %d", phaseSpanCount) + } +} + +func TestOTelReporter_PhaseSpanWithError(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Start build and package + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Simulate phase with error + reporter.PackageBuildPhaseStarted(pkg, PackageBuildPhaseBuild) + buildErr := fmt.Errorf("build failed") + reporter.PackageBuildPhaseFinished(pkg, PackageBuildPhaseBuild, buildErr) + + // Finish package and build + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhaseBuild}, + Error: buildErr, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, buildErr) + + // Verify spans were created + spans := exporter.GetSpans() + + // Find phase span + var phaseSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.phase" { + phaseSpan = &spans[i] + break + } + } + + if phaseSpan == nil { + t.Fatal("Expected to find phase span") + } + + // Verify error status + if phaseSpan.Status.Code != codes.Error { + t.Errorf("Expected phase span status Error, got %v", phaseSpan.Status.Code) + } + + // Verify error was recorded + if len(phaseSpan.Events) == 0 { + t.Error("Expected error event to be recorded in phase span") + } +} + +func TestOTelReporter_PhaseSpanHierarchy(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Start build and package + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Execute phase + reporter.PackageBuildPhaseStarted(pkg, PackageBuildPhaseBuild) + reporter.PackageBuildPhaseFinished(pkg, PackageBuildPhaseBuild, nil) + + // Finish package and build + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhaseBuild}, + Error: nil, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, nil) + + // Verify span hierarchy + spans := exporter.GetSpans() + + var buildSpan, packageSpan, phaseSpan *tracetest.SpanStub + for i := range spans { + switch spans[i].Name { + case "leeway.build": + buildSpan = &spans[i] + case "leeway.package": + packageSpan = &spans[i] + case "leeway.phase": + phaseSpan = &spans[i] + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + if packageSpan == nil { + t.Fatal("Expected to find package span") + } + if phaseSpan == nil { + t.Fatal("Expected to find phase span") + } + + // Verify parent-child relationships + // Package span should be child of build span + if packageSpan.Parent.TraceID() != buildSpan.SpanContext.TraceID() { + t.Error("Package span should have same trace ID as build span") + } + if packageSpan.Parent.SpanID() != buildSpan.SpanContext.SpanID() { + t.Error("Package span should be child of build span") + } + + // Phase span should be child of package span + if phaseSpan.Parent.TraceID() != packageSpan.SpanContext.TraceID() { + t.Error("Phase span should have same trace ID as package span") + } + if phaseSpan.Parent.SpanID() != packageSpan.SpanContext.SpanID() { + t.Error("Phase span should be child of package span") + } +} + +func TestOTelReporter_PhaseAwareInterface(t *testing.T) { + // Verify OTelReporter implements PhaseAwareReporter + var _ PhaseAwareReporter = (*OTelReporter)(nil) + + // Verify NoopReporter does NOT implement PhaseAwareReporter + var noop Reporter = &NoopReporter{} + if _, ok := noop.(PhaseAwareReporter); ok { + t.Error("NoopReporter should not implement PhaseAwareReporter") + } +} + +func TestOTelReporter_PhaseWithoutPackageContext(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Try to start phase without starting package first + // This should not panic and should log a warning + reporter.PackageBuildPhaseStarted(pkg, PackageBuildPhaseBuild) + reporter.PackageBuildPhaseFinished(pkg, PackageBuildPhaseBuild, nil) + + // Verify no phase spans were created + spans := exporter.GetSpans() + for _, span := range spans { + if span.Name == "leeway.phase" { + t.Error("Phase span should not be created without package context") + } + } +} diff --git a/pkg/leeway/reporter_otel_test.go b/pkg/leeway/reporter_otel_test.go new file mode 100644 index 00000000..707198a1 --- /dev/null +++ b/pkg/leeway/reporter_otel_test.go @@ -0,0 +1,1051 @@ +package leeway + +import ( + "context" + "fmt" + "os" + "strings" + "testing" + "time" + + "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestOTelReporter_BuildLifecycle(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Test build lifecycle + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be created") + } + + // Verify root span + rootSpan := spans[len(spans)-1] + if rootSpan.Name != "leeway.build" { + t.Errorf("Expected root span name 'leeway.build', got '%s'", rootSpan.Name) + } + + // Verify attributes + attrs := rootSpan.Attributes + hasTargetPackage := false + for _, attr := range attrs { + if string(attr.Key) == "leeway.target.package" { + hasTargetPackage = true + if attr.Value.AsString() != "test-component:test-package" { + t.Errorf("Expected target package 'test-component:test-package', got '%s'", attr.Value.AsString()) + } + } + } + if !hasTargetPackage { + t.Error("Expected 'leeway.target.package' attribute in root span") + } +} + +func TestOTelReporter_PackageLifecycle(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test package + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + // Start build first + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + reporter.BuildStarted(pkg, status) + + // Test package lifecycle + reporter.PackageBuildStarted(pkg, "/tmp/build") + + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep, PackageBuildPhaseBuild}, + Error: nil, + } + reporter.PackageBuildFinished(pkg, rep) + + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) < 2 { + t.Fatalf("Expected at least 2 spans (build + package), got %d", len(spans)) + } + + // Find package span + var packageSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.package" { + packageSpan = &spans[i] + break + } + } + + if packageSpan == nil { + t.Fatal("Expected to find package span") + } + + // Verify package attributes + hasPackageName := false + hasPackageType := false + for _, attr := range packageSpan.Attributes { + switch string(attr.Key) { + case "leeway.package.name": + hasPackageName = true + if attr.Value.AsString() != "test-component:test-package" { + t.Errorf("Expected package name 'test-component:test-package', got '%s'", attr.Value.AsString()) + } + case "leeway.package.type": + hasPackageType = true + if attr.Value.AsString() != string(GenericPackage) { + t.Errorf("Expected package type '%s', got '%s'", GenericPackage, attr.Value.AsString()) + } + } + } + + if !hasPackageName { + t.Error("Expected 'leeway.package.name' attribute in package span") + } + if !hasPackageType { + t.Error("Expected 'leeway.package.type' attribute in package span") + } +} + +func TestOTelReporter_NilTracer(t *testing.T) { + // Reporter with nil tracer should not panic + reporter := NewOTelReporter(nil, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + // These should not panic + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + reporter.PackageBuildFinished(pkg, &PackageBuildReport{}) + reporter.BuildFinished(pkg, nil) +} + +func TestOTelReporter_ConcurrentPackages(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create test packages + pkg1 := &Package{ + C: &Component{ + Name: "component1", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "package1", + Type: GenericPackage, + }, + } + + pkg2 := &Package{ + C: &Component{ + Name: "component2", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "package2", + Type: GenericPackage, + }, + } + + // Start build + status := map[*Package]PackageBuildStatus{ + pkg1: PackageNotBuiltYet, + pkg2: PackageNotBuiltYet, + } + reporter.BuildStarted(pkg1, status) + + // Build packages concurrently + done := make(chan bool, 2) + + go func() { + reporter.PackageBuildStarted(pkg1, "/tmp/build1") + reporter.PackageBuildFinished(pkg1, &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep}, + }) + done <- true + }() + + go func() { + reporter.PackageBuildStarted(pkg2, "/tmp/build2") + reporter.PackageBuildFinished(pkg2, &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep}, + }) + done <- true + }() + + // Wait for both to complete + <-done + <-done + + reporter.BuildFinished(pkg1, nil) + + // Verify we got spans for both packages + spans := exporter.GetSpans() + packageSpanCount := 0 + for _, span := range spans { + if span.Name == "leeway.package" { + packageSpanCount++ + } + } + + if packageSpanCount != 2 { + t.Errorf("Expected 2 package spans, got %d", packageSpanCount) + } +} + +func TestOTelReporter_WithParentContext(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + // Create parent span + tracer := tp.Tracer("test") + parentCtx, parentSpan := tracer.Start(context.Background(), "parent-span") + parentSpanID := parentSpan.SpanContext().SpanID() + + // Create reporter with parent context + reporter := NewOTelReporter(tracer, parentCtx) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.BuildFinished(pkg, nil) + + // End parent span so it appears in exporter + parentSpan.End() + + // Verify spans were created and have parent relationship + spans := exporter.GetSpans() + if len(spans) < 2 { + t.Fatalf("Expected at least 2 spans (parent + build), got %d", len(spans)) + } + + // Find build span + var buildSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.build" { + buildSpan = &spans[i] + break + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + + // Verify parent relationship + if buildSpan.Parent.SpanID() != parentSpanID { + t.Error("Build span should have parent span as parent") + } +} + +func TestOTelReporter_GitHubAttributes(t *testing.T) { + // Save and restore environment + githubVars := []string{ + "GITHUB_ACTIONS", + "GITHUB_WORKFLOW", + "GITHUB_RUN_ID", + "GITHUB_RUN_NUMBER", + "GITHUB_JOB", + "GITHUB_ACTOR", + "GITHUB_REPOSITORY", + "GITHUB_REF", + "GITHUB_SHA", + "GITHUB_SERVER_URL", + "GITHUB_WORKFLOW_REF", + } + + oldVars := make(map[string]string) + for _, key := range githubVars { + oldVars[key] = os.Getenv(key) + } + + defer func() { + for key, val := range oldVars { + if val == "" { + _ = os.Unsetenv(key) + } else { + _ = os.Setenv(key, val) + } + } + }() + + // Set GitHub environment variables + _ = os.Setenv("GITHUB_ACTIONS", "true") + _ = os.Setenv("GITHUB_WORKFLOW", "test-workflow") + _ = os.Setenv("GITHUB_RUN_ID", "123456789") + _ = os.Setenv("GITHUB_RUN_NUMBER", "42") + _ = os.Setenv("GITHUB_JOB", "test-job") + _ = os.Setenv("GITHUB_ACTOR", "test-user") + _ = os.Setenv("GITHUB_REPOSITORY", "test-org/test-repo") + _ = os.Setenv("GITHUB_REF", "refs/heads/main") + _ = os.Setenv("GITHUB_SHA", "abc123def456") + _ = os.Setenv("GITHUB_SERVER_URL", "https://github.com") + _ = os.Setenv("GITHUB_WORKFLOW_REF", "test-org/test-repo/.github/workflows/test.yml@refs/heads/main") + + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be created") + } + + // Find build span + var buildSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.build" { + buildSpan = &spans[i] + break + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + + // Verify GitHub attributes are present + expectedAttrs := map[string]string{ + "github.workflow": "test-workflow", + "github.run_id": "123456789", + "github.run_number": "42", + "github.job": "test-job", + "github.actor": "test-user", + "github.repository": "test-org/test-repo", + "github.ref": "refs/heads/main", + "github.sha": "abc123def456", + "github.server_url": "https://github.com", + "github.workflow_ref": "test-org/test-repo/.github/workflows/test.yml@refs/heads/main", + } + + foundAttrs := make(map[string]string) + for _, attr := range buildSpan.Attributes { + key := string(attr.Key) + if strings.HasPrefix(key, "github.") { + foundAttrs[key] = attr.Value.AsString() + } + } + + // Check all expected attributes are present with correct values + for key, expectedValue := range expectedAttrs { + actualValue, found := foundAttrs[key] + if !found { + t.Errorf("Expected GitHub attribute '%s' not found in span", key) + } else if actualValue != expectedValue { + t.Errorf("GitHub attribute '%s': expected '%s', got '%s'", key, expectedValue, actualValue) + } + } + + // Verify we found all expected attributes + if len(foundAttrs) != len(expectedAttrs) { + t.Errorf("Expected %d GitHub attributes, found %d", len(expectedAttrs), len(foundAttrs)) + } +} + +func TestOTelReporter_NoGitHubAttributes(t *testing.T) { + // Save and restore GITHUB_ACTIONS + oldValue := os.Getenv("GITHUB_ACTIONS") + defer func() { + if oldValue == "" { + _ = os.Unsetenv("GITHUB_ACTIONS") + } else { + _ = os.Setenv("GITHUB_ACTIONS", oldValue) + } + }() + + // Ensure GITHUB_ACTIONS is not set + _ = os.Unsetenv("GITHUB_ACTIONS") + + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be created") + } + + // Find build span + var buildSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.build" { + buildSpan = &spans[i] + break + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + + // Verify NO GitHub attributes are present + for _, attr := range buildSpan.Attributes { + key := string(attr.Key) + if strings.HasPrefix(key, "github.") { + t.Errorf("Unexpected GitHub attribute '%s' found when GITHUB_ACTIONS is not set", key) + } + } +} + +func TestOTelReporter_BuildError(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + + // Simulate build error + buildErr := fmt.Errorf("build failed: compilation error") + reporter.BuildFinished(pkg, buildErr) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be created") + } + + // Find build span + var buildSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.build" { + buildSpan = &spans[i] + break + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + + // Verify error status + if buildSpan.Status.Code != 1 { // codes.Error = 1 + t.Errorf("Expected error status code 1, got %d", buildSpan.Status.Code) + } + + if buildSpan.Status.Description != "build failed: compilation error" { + t.Errorf("Expected error description 'build failed: compilation error', got '%s'", buildSpan.Status.Description) + } +} + +func TestOTelReporter_PackageBuildError(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Simulate package build error + pkgErr := fmt.Errorf("package build failed: test failure") + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep, PackageBuildPhaseTest}, + Error: pkgErr, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) < 2 { + t.Fatalf("Expected at least 2 spans, got %d", len(spans)) + } + + // Find package span + var packageSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.package" { + packageSpan = &spans[i] + break + } + } + + if packageSpan == nil { + t.Fatal("Expected to find package span") + } + + // Verify error status + if packageSpan.Status.Code != 1 { // codes.Error = 1 + t.Errorf("Expected error status code 1, got %d", packageSpan.Status.Code) + } + + if packageSpan.Status.Description != "package build failed: test failure" { + t.Errorf("Expected error description 'package build failed: test failure', got '%s'", packageSpan.Status.Description) + } +} + +func TestOTelReporter_TestCoverageAttributes(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Report with test coverage + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep, PackageBuildPhaseTest}, + TestCoverageAvailable: true, + TestCoveragePercentage: 85, + FunctionsWithTest: 42, + FunctionsWithoutTest: 8, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) < 2 { + t.Fatalf("Expected at least 2 spans, got %d", len(spans)) + } + + // Find package span + var packageSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.package" { + packageSpan = &spans[i] + break + } + } + + if packageSpan == nil { + t.Fatal("Expected to find package span") + } + + // Verify test coverage attributes + expectedAttrs := map[string]int64{ + "leeway.package.test.coverage_percentage": 85, + "leeway.package.test.functions_with_test": 42, + "leeway.package.test.functions_without_test": 8, + } + + foundAttrs := make(map[string]int64) + for _, attr := range packageSpan.Attributes { + key := string(attr.Key) + if strings.HasPrefix(key, "leeway.package.test.") { + foundAttrs[key] = attr.Value.AsInt64() + } + } + + for key, expectedValue := range expectedAttrs { + actualValue, found := foundAttrs[key] + if !found { + t.Errorf("Expected test coverage attribute '%s' not found", key) + } else if actualValue != expectedValue { + t.Errorf("Test coverage attribute '%s': expected %d, got %d", key, expectedValue, actualValue) + } + } +} + +func TestOTelReporter_PhaseDurations(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Simulate phase execution with actual phase spans + phases := []PackageBuildPhase{PackageBuildPhasePrep, PackageBuildPhaseBuild, PackageBuildPhaseTest} + for _, phase := range phases { + reporter.PackageBuildPhaseStarted(pkg, phase) + time.Sleep(10 * time.Millisecond) // Simulate work + reporter.PackageBuildPhaseFinished(pkg, phase, nil) + } + + // Create report with phase durations + now := time.Now() + rep := &PackageBuildReport{ + phaseEnter: map[PackageBuildPhase]time.Time{ + PackageBuildPhasePrep: now, + PackageBuildPhaseBuild: now.Add(100 * time.Millisecond), + PackageBuildPhaseTest: now.Add(300 * time.Millisecond), + }, + phaseDone: map[PackageBuildPhase]time.Time{ + PackageBuildPhasePrep: now.Add(100 * time.Millisecond), + PackageBuildPhaseBuild: now.Add(300 * time.Millisecond), + PackageBuildPhaseTest: now.Add(500 * time.Millisecond), + }, + Phases: phases, + } + reporter.PackageBuildFinished(pkg, rep) + reporter.BuildFinished(pkg, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) < 5 { // build + package + 3 phases + t.Fatalf("Expected at least 5 spans, got %d", len(spans)) + } + + // Verify phase spans exist (durations are now in nested spans, not attributes) + expectedPhases := []string{"prep", "build", "test"} + foundPhases := make(map[string]bool) + + for _, span := range spans { + if span.Name == "leeway.phase" { + for _, attr := range span.Attributes { + if string(attr.Key) == "leeway.phase.name" { + phaseName := attr.Value.AsString() + foundPhases[phaseName] = true + + // Verify span has reasonable duration + duration := span.EndTime.Sub(span.StartTime) + if duration < 5*time.Millisecond || duration > 100*time.Millisecond { + t.Errorf("Phase '%s' duration %v seems unreasonable", phaseName, duration) + } + } + } + } + } + + for _, phase := range expectedPhases { + if !foundPhases[phase] { + t.Errorf("Expected phase span for '%s' not found", phase) + } + } +} + +func TestOTelReporter_PackageBuildStatusCounts(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + // Create multiple packages with different statuses + pkg1 := &Package{ + C: &Component{ + Name: "component1", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "package1", + Type: GenericPackage, + }, + } + + pkg2 := &Package{ + C: &Component{ + Name: "component2", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "package2", + Type: GenericPackage, + }, + } + + pkg3 := &Package{ + C: &Component{ + Name: "component3", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "package3", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg1: PackageBuilt, + pkg2: PackageInRemoteCache, + pkg3: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg1, status) + reporter.BuildFinished(pkg1, nil) + + // Verify spans were created + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("Expected at least one span to be created") + } + + // Find build span + var buildSpan *tracetest.SpanStub + for i := range spans { + if spans[i].Name == "leeway.build" { + buildSpan = &spans[i] + break + } + } + + if buildSpan == nil { + t.Fatal("Expected to find build span") + } + + // Verify package status counts + expectedCounts := map[string]int64{ + "leeway.packages.total": 3, + "leeway.packages.cached": 1, + "leeway.packages.remote": 1, + "leeway.packages.to_build": 1, + "leeway.packages.downloaded": 0, + } + + foundCounts := make(map[string]int64) + for _, attr := range buildSpan.Attributes { + key := string(attr.Key) + if strings.HasPrefix(key, "leeway.packages.") { + foundCounts[key] = attr.Value.AsInt64() + } + } + + for key, expectedValue := range expectedCounts { + actualValue, found := foundCounts[key] + if !found { + t.Errorf("Expected package count attribute '%s' not found", key) + } else if actualValue != expectedValue { + t.Errorf("Package count attribute '%s': expected %d, got %d", key, expectedValue, actualValue) + } + } +} + +func TestOTelReporter_MemoryCleanup(t *testing.T) { + // Create in-memory exporter for testing + exporter := tracetest.NewInMemoryExporter() + tp := trace.NewTracerProvider( + trace.WithSyncer(exporter), + ) + defer func() { + _ = tp.Shutdown(context.Background()) + }() + + tracer := tp.Tracer("test") + reporter := NewOTelReporter(tracer, context.Background()) + + pkg := &Package{ + C: &Component{ + Name: "test-component", + W: &Workspace{ + Origin: "/workspace", + }, + }, + PackageInternal: PackageInternal{ + Name: "test-package", + Type: GenericPackage, + }, + } + + status := map[*Package]PackageBuildStatus{ + pkg: PackageNotBuiltYet, + } + + reporter.BuildStarted(pkg, status) + reporter.PackageBuildStarted(pkg, "/tmp/build") + + // Verify maps are populated + reporter.mu.RLock() + if len(reporter.packageSpans) != 1 { + t.Errorf("Expected 1 package span in map, got %d", len(reporter.packageSpans)) + } + if len(reporter.packageCtxs) != 1 { + t.Errorf("Expected 1 package context in map, got %d", len(reporter.packageCtxs)) + } + reporter.mu.RUnlock() + + // Finish package build + rep := &PackageBuildReport{ + phaseEnter: make(map[PackageBuildPhase]time.Time), + phaseDone: make(map[PackageBuildPhase]time.Time), + Phases: []PackageBuildPhase{PackageBuildPhasePrep}, + } + reporter.PackageBuildFinished(pkg, rep) + + // Verify maps are cleaned up + reporter.mu.RLock() + if len(reporter.packageSpans) != 0 { + t.Errorf("Expected package spans map to be empty after PackageBuildFinished, got %d entries", len(reporter.packageSpans)) + } + if len(reporter.packageCtxs) != 0 { + t.Errorf("Expected package contexts map to be empty after PackageBuildFinished, got %d entries", len(reporter.packageCtxs)) + } + reporter.mu.RUnlock() + + reporter.BuildFinished(pkg, nil) +} diff --git a/pkg/leeway/telemetry/tracer.go b/pkg/leeway/telemetry/tracer.go new file mode 100644 index 00000000..2fc90d76 --- /dev/null +++ b/pkg/leeway/telemetry/tracer.go @@ -0,0 +1,189 @@ +package telemetry + +import ( + "context" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.24.0" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" +) + +// leewayVersion is set by the build system and used for telemetry +var leewayVersion = "unknown" + +// SetLeewayVersion sets the leeway version for telemetry reporting +func SetLeewayVersion(version string) { + if version != "" { + leewayVersion = version + } +} + +// InitTracer initializes the OpenTelemetry tracer with OTLP HTTP exporter. +// The endpoint parameter specifies the OTLP endpoint URL (e.g., "localhost:4318"). +// The insecure parameter controls whether to use TLS (false = use TLS, true = no TLS). +// Returns the TracerProvider which must be shut down when done. +func InitTracer(ctx context.Context, endpoint string, insecure bool) (*sdktrace.TracerProvider, error) { + if endpoint == "" { + return nil, xerrors.Errorf("OTLP endpoint not provided") + } + + // Create OTLP HTTP exporter with optional TLS + opts := []otlptracehttp.Option{ + otlptracehttp.WithEndpoint(endpoint), + } + if insecure { + opts = append(opts, otlptracehttp.WithInsecure()) + } + + exporter, err := otlptracehttp.New(ctx, opts...) + if err != nil { + return nil, xerrors.Errorf("failed to create OTLP exporter: %w", err) + } + + // Create resource with service information + res, err := resource.New(ctx, + resource.WithAttributes( + semconv.ServiceNameKey.String("leeway"), + semconv.ServiceVersionKey.String(getLeewayVersion()), + ), + ) + if err != nil { + return nil, xerrors.Errorf("failed to create resource: %w", err) + } + + // Create tracer provider + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + + // Set global tracer provider + otel.SetTracerProvider(tp) + + // Set global propagator for W3C Trace Context + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return tp, nil +} + +// Shutdown flushes any pending spans and shuts down the tracer provider. +// It uses a timeout context to ensure shutdown completes within a reasonable time. +func Shutdown(ctx context.Context, tp *sdktrace.TracerProvider) error { + if tp == nil { + return nil + } + + // Create a timeout context for shutdown + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := tp.Shutdown(shutdownCtx); err != nil { + return xerrors.Errorf("failed to shutdown tracer provider: %w", err) + } + + return nil +} + +// ParseTraceContext parses W3C Trace Context headers (traceparent and tracestate) +// and returns a context with the extracted trace information. +// Format: traceparent = "00-{trace-id}-{span-id}-{flags}" +func ParseTraceContext(traceparent, tracestate string) (context.Context, error) { + if traceparent == "" { + return context.Background(), nil + } + + // Create a carrier with the trace context headers + carrier := propagation.MapCarrier{ + "traceparent": traceparent, + } + if tracestate != "" { + carrier["tracestate"] = tracestate + } + + // Extract the trace context using W3C Trace Context propagator + ctx := context.Background() + propagator := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + ctx = propagator.Extract(ctx, carrier) + + // Verify that we extracted a valid span context + spanCtx := trace.SpanContextFromContext(ctx) + if !spanCtx.IsValid() { + return nil, xerrors.Errorf("invalid trace context: traceparent=%s", traceparent) + } + + return ctx, nil +} + +// getLeewayVersion returns the leeway version for telemetry. +func getLeewayVersion() string { + return leewayVersion +} + +// FormatTraceContext formats a span context into W3C Trace Context format. +// This is useful for propagating trace context to child processes. +func FormatTraceContext(spanCtx trace.SpanContext) (traceparent, tracestate string) { + if !spanCtx.IsValid() { + return "", "" + } + + // Use the propagator to format the trace context properly + carrier := propagation.MapCarrier{} + propagator := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + ctx := trace.ContextWithSpanContext(context.Background(), spanCtx) + propagator.Inject(ctx, carrier) + + traceparent = carrier.Get("traceparent") + tracestate = carrier.Get("tracestate") + + return traceparent, tracestate +} + +// ValidateTraceParent validates the format of a traceparent header. +func ValidateTraceParent(traceparent string) error { + if traceparent == "" { + return nil + } + + parts := strings.Split(traceparent, "-") + if len(parts) != 4 { + return xerrors.Errorf("invalid traceparent format: expected 4 parts, got %d", len(parts)) + } + + // Validate version + if parts[0] != "00" { + return xerrors.Errorf("unsupported traceparent version: %s", parts[0]) + } + + // Validate trace ID length (32 hex chars) + if len(parts[1]) != 32 { + return xerrors.Errorf("invalid trace ID length: expected 32, got %d", len(parts[1])) + } + + // Validate span ID length (16 hex chars) + if len(parts[2]) != 16 { + return xerrors.Errorf("invalid span ID length: expected 16, got %d", len(parts[2])) + } + + // Validate flags length (2 hex chars) + if len(parts[3]) != 2 { + return xerrors.Errorf("invalid flags length: expected 2, got %d", len(parts[3])) + } + + return nil +} diff --git a/pkg/leeway/telemetry/tracer_test.go b/pkg/leeway/telemetry/tracer_test.go new file mode 100644 index 00000000..bba3b776 --- /dev/null +++ b/pkg/leeway/telemetry/tracer_test.go @@ -0,0 +1,182 @@ +package telemetry + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/trace" +) + +func TestParseTraceContext(t *testing.T) { + tests := []struct { + name string + traceparent string + tracestate string + wantErr bool + wantValid bool + }{ + { + name: "valid traceparent", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + tracestate: "", + wantErr: false, + wantValid: true, + }, + { + name: "valid traceparent with tracestate", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + tracestate: "congo=t61rcWkgMzE", + wantErr: false, + wantValid: true, + }, + { + name: "empty traceparent", + traceparent: "", + tracestate: "", + wantErr: false, + wantValid: false, + }, + { + name: "invalid traceparent", + traceparent: "invalid", + tracestate: "", + wantErr: true, + wantValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, err := ParseTraceContext(tt.traceparent, tt.tracestate) + if (err != nil) != tt.wantErr { + t.Errorf("ParseTraceContext() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil { + spanCtx := trace.SpanContextFromContext(ctx) + if spanCtx.IsValid() != tt.wantValid { + t.Errorf("ParseTraceContext() span context valid = %v, want %v", spanCtx.IsValid(), tt.wantValid) + } + } + }) + } +} + +func TestValidateTraceParent(t *testing.T) { + tests := []struct { + name string + traceparent string + wantErr bool + }{ + { + name: "valid traceparent", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + wantErr: false, + }, + { + name: "empty traceparent", + traceparent: "", + wantErr: false, + }, + { + name: "invalid format - too few parts", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7", + wantErr: true, + }, + { + name: "invalid format - too many parts", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01-extra", + wantErr: true, + }, + { + name: "invalid version", + traceparent: "ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + wantErr: true, + }, + { + name: "invalid trace ID length", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01", + wantErr: true, + }, + { + name: "invalid span ID length", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b-01", + wantErr: true, + }, + { + name: "invalid flags length", + traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateTraceParent(tt.traceparent) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateTraceParent() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestFormatTraceContext(t *testing.T) { + // Create a valid span context + traceID, _ := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + spanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + spanCtx := trace.NewSpanContext(trace.SpanContextConfig{ + TraceID: traceID, + SpanID: spanID, + TraceFlags: trace.FlagsSampled, + }) + + traceparent, tracestate := FormatTraceContext(spanCtx) + + // Validate format + if err := ValidateTraceParent(traceparent); err != nil { + t.Errorf("FormatTraceContext() produced invalid traceparent: %v", err) + } + + // Verify it can be parsed back + ctx, err := ParseTraceContext(traceparent, tracestate) + if err != nil { + t.Errorf("FormatTraceContext() produced unparseable traceparent: %v", err) + } + + parsedSpanCtx := trace.SpanContextFromContext(ctx) + if !parsedSpanCtx.IsValid() { + t.Error("FormatTraceContext() produced invalid span context after round-trip") + } + + if parsedSpanCtx.TraceID() != traceID { + t.Errorf("FormatTraceContext() trace ID mismatch: got %v, want %v", parsedSpanCtx.TraceID(), traceID) + } +} + +func TestFormatTraceContext_Invalid(t *testing.T) { + // Test with invalid span context + spanCtx := trace.SpanContext{} + traceparent, tracestate := FormatTraceContext(spanCtx) + + if traceparent != "" { + t.Errorf("FormatTraceContext() with invalid span context should return empty traceparent, got %v", traceparent) + } + if tracestate != "" { + t.Errorf("FormatTraceContext() with invalid span context should return empty tracestate, got %v", tracestate) + } +} + +func TestInitTracer_NoEndpoint(t *testing.T) { + _, err := InitTracer(context.Background(), "", false) + if err == nil { + t.Error("InitTracer() should fail when endpoint is empty") + } +} + +func TestShutdown_NilProvider(t *testing.T) { + // Should not panic with nil provider + err := Shutdown(context.Background(), nil) + if err != nil { + t.Errorf("Shutdown() with nil provider should not return error, got %v", err) + } +}