Skip to content

Commit b11b412

Browse files
corneliusludmannona-agent
authored andcommitted
feat: add nested phase spans for OpenTelemetry tracing
Add PhaseAwareReporter optional interface to enable phase-level span creation without breaking existing reporters. Phase spans are created as children of package spans for detailed build timeline visualization. Changes: - Define PhaseAwareReporter interface with phase start/finish methods - Implement phase span tracking in OTelReporter - Modify executeBuildPhase to call phase-aware reporters via type assertion - Remove phase duration attributes (now captured in nested spans) - Add comprehensive phase span tests - Update documentation with span hierarchy and phase attributes Closes CLC-2107 Co-authored-by: Ona <no-reply@ona.com>
1 parent 05d3912 commit b11b412

File tree

6 files changed

+487
-41
lines changed

6 files changed

+487
-41
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,25 @@ export OTEL_EXPORTER_OTLP_HEADERS="x-honeycomb-team=YOUR_API_KEY"
611611
leeway build :my-package
612612
```
613613

614+
The OpenTelemetry SDK automatically reads standard `OTEL_EXPORTER_OTLP_*` environment variables.
615+
616+
## Span Hierarchy
617+
618+
Leeway creates a nested span hierarchy for detailed build timeline visualization:
619+
620+
```
621+
leeway.build (root)
622+
├── leeway.package (component:package-1)
623+
│ ├── leeway.phase (prep)
624+
│ ├── leeway.phase (build)
625+
│ └── leeway.phase (test)
626+
└── leeway.package (component:package-2)
627+
├── leeway.phase (prep)
628+
└── leeway.phase (build)
629+
```
630+
631+
Each phase span captures timing, status, and errors for individual build phases (prep, pull, lint, test, build, package).
632+
614633
See [docs/observability.md](docs/observability.md) for configuration, examples, and span attributes.
615634

616635
# Provenance (SLSA) - EXPERIMENTAL

docs/observability.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,24 @@ OpenTelemetry tracing in leeway captures:
1919
```
2020
Root Span (leeway.build)
2121
├── Package Span 1 (leeway.package)
22+
│ ├── Phase Span (leeway.phase: prep)
23+
│ ├── Phase Span (leeway.phase: pull)
24+
│ ├── Phase Span (leeway.phase: lint)
25+
│ ├── Phase Span (leeway.phase: test)
26+
│ ├── Phase Span (leeway.phase: build)
27+
│ └── Phase Span (leeway.phase: package)
2228
├── Package Span 2 (leeway.package)
29+
│ ├── Phase Span (leeway.phase: prep)
30+
│ └── Phase Span (leeway.phase: build)
2331
└── Package Span N (leeway.package)
32+
└── ...
2433
```
2534

2635
- **Root Span**: Created when `BuildStarted` is called, represents the entire build operation
2736
- **Package Spans**: Created for each package being built, as children of the root span
37+
- **Phase Spans**: Created for each build phase (prep, pull, lint, test, build, package) as children of package spans
2838

29-
Build phase durations (prep, pull, lint, test, build, package) are captured as attributes on package spans, not as separate spans. This design provides lower overhead and simpler hierarchy while maintaining visibility into phase-level performance.
39+
Phase spans provide detailed timeline visualization and capture individual phase errors. Only phases with commands are executed and create spans.
3040

3141
### Context Propagation
3242

@@ -35,6 +45,7 @@ Leeway supports W3C Trace Context propagation, allowing builds to be part of lar
3545
1. **Parent Context**: Accepts `traceparent` and `tracestate` headers from upstream systems
3646
2. **Root Context**: Creates a root span linked to the parent context
3747
3. **Package Context**: Each package span is a child of the root span
48+
4. **Phase Context**: Each phase span is a child of its package span
3849

3950
## Configuration
4051

@@ -109,11 +120,24 @@ leeway build :my-package
109120
| `leeway.package.builddir` | string | Build directory | `"/tmp/leeway/build/..."` |
110121
| `leeway.package.last_phase` | string | Last completed phase | `"build"` |
111122
| `leeway.package.duration_ms` | int64 | Total build duration (ms) | `15234` |
112-
| `leeway.package.phase.{phase}.duration_ms` | int64 | Phase duration (ms) | `5432` |
113123
| `leeway.package.test.coverage_percentage` | int | Test coverage % | `85` |
114124
| `leeway.package.test.functions_with_test` | int | Functions with tests | `42` |
115125
| `leeway.package.test.functions_without_test` | int | Functions without tests | `8` |
116126

127+
### Phase Span Attributes
128+
129+
Phase spans are created for each build phase (prep, pull, lint, test, build, package) that has commands to execute.
130+
131+
| Attribute | Type | Description | Example |
132+
|-----------|------|-------------|---------|
133+
| `leeway.phase.name` | string | Phase name | `"prep"`, `"build"`, `"test"`, etc. |
134+
135+
**Span Status:**
136+
- `OK`: Phase completed successfully
137+
- `ERROR`: Phase failed (error details in span events)
138+
139+
**Span Duration:** The span's start and end times capture the phase execution duration automatically.
140+
117141
### GitHub Actions Attributes
118142

119143
When running in GitHub Actions (`GITHUB_ACTIONS=true`), the following attributes are added to the root span:

pkg/leeway/build.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,11 @@ func executeBuildPhase(buildctx *buildContext, p *Package, builddir string, bld
13651365
return nil
13661366
}
13671367

1368+
// Notify phase-aware reporters
1369+
if par, ok := buildctx.Reporter.(PhaseAwareReporter); ok {
1370+
par.PackageBuildPhaseStarted(p, phase)
1371+
}
1372+
13681373
if phase != PackageBuildPhasePrep {
13691374
pkgRep.phaseEnter[phase] = time.Now()
13701375
pkgRep.Phases = append(pkgRep.Phases, phase)
@@ -1375,6 +1380,11 @@ func executeBuildPhase(buildctx *buildContext, p *Package, builddir string, bld
13751380
err := executeCommandsForPackage(buildctx, p, builddir, cmds)
13761381
pkgRep.phaseDone[phase] = time.Now()
13771382

1383+
// Notify phase-aware reporters
1384+
if par, ok := buildctx.Reporter.(PhaseAwareReporter); ok {
1385+
par.PackageBuildPhaseFinished(p, phase, err)
1386+
}
1387+
13781388
return err
13791389
}
13801390

pkg/leeway/reporter.go

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ type Reporter interface {
5252
PackageBuildFinished(pkg *Package, rep *PackageBuildReport)
5353
}
5454

55+
// PhaseAwareReporter is an optional interface that reporters can implement
56+
// to receive phase-level notifications for creating nested spans or tracking.
57+
// This follows the Go pattern of optional interfaces (like io.Closer, io.Seeker).
58+
type PhaseAwareReporter interface {
59+
Reporter
60+
// PackageBuildPhaseStarted is called when a build phase starts
61+
PackageBuildPhaseStarted(pkg *Package, phase PackageBuildPhase)
62+
// PackageBuildPhaseFinished is called when a build phase completes
63+
PackageBuildPhaseFinished(pkg *Package, phase PackageBuildPhase, err error)
64+
}
65+
5566
type PackageBuildReport struct {
5667
phaseEnter map[PackageBuildPhase]time.Time
5768
phaseDone map[PackageBuildPhase]time.Time
@@ -701,6 +712,7 @@ type OTelReporter struct {
701712
rootSpan trace.Span
702713
packageCtxs map[string]context.Context
703714
packageSpans map[string]trace.Span
715+
phaseSpans map[string]trace.Span // key: "packageName:phaseName"
704716
mu sync.RWMutex
705717
}
706718

@@ -714,6 +726,7 @@ func NewOTelReporter(tracer trace.Tracer, parentCtx context.Context) *OTelReport
714726
parentCtx: parentCtx,
715727
packageCtxs: make(map[string]context.Context),
716728
packageSpans: make(map[string]trace.Span),
729+
phaseSpans: make(map[string]trace.Span),
717730
}
718731
}
719732

@@ -866,16 +879,6 @@ func (r *OTelReporter) PackageBuildFinished(pkg *Package, rep *PackageBuildRepor
866879
attribute.Int64("leeway.package.duration_ms", rep.TotalTime().Milliseconds()),
867880
)
868881

869-
// Add phase durations
870-
for _, phase := range rep.Phases {
871-
duration := rep.PhaseDuration(phase)
872-
if duration >= 0 {
873-
span.SetAttributes(
874-
attribute.Int64(fmt.Sprintf("leeway.package.phase.%s.duration_ms", phase), duration.Milliseconds()),
875-
)
876-
}
877-
}
878-
879882
// Add test coverage if available
880883
if rep.TestCoverageAvailable {
881884
span.SetAttributes(
@@ -901,6 +904,70 @@ func (r *OTelReporter) PackageBuildFinished(pkg *Package, rep *PackageBuildRepor
901904
delete(r.packageCtxs, pkgName)
902905
}
903906

907+
// PackageBuildPhaseStarted implements PhaseAwareReporter
908+
func (r *OTelReporter) PackageBuildPhaseStarted(pkg *Package, phase PackageBuildPhase) {
909+
if r.tracer == nil {
910+
return
911+
}
912+
913+
r.mu.Lock()
914+
defer r.mu.Unlock()
915+
916+
pkgName := pkg.FullName()
917+
packageCtx, ok := r.packageCtxs[pkgName]
918+
if !ok {
919+
log.WithField("package", pkgName).Warn("PackageBuildPhaseStarted called without package context")
920+
return
921+
}
922+
923+
// Create phase span as child of package span
924+
phaseKey := fmt.Sprintf("%s:%s", pkgName, phase)
925+
ctx, span := r.tracer.Start(packageCtx, "leeway.phase",
926+
trace.WithSpanKind(trace.SpanKindInternal),
927+
)
928+
929+
// Add phase attributes
930+
span.SetAttributes(
931+
attribute.String("leeway.phase.name", string(phase)),
932+
)
933+
934+
// Store phase span and update package context
935+
r.phaseSpans[phaseKey] = span
936+
r.packageCtxs[pkgName] = ctx
937+
}
938+
939+
// PackageBuildPhaseFinished implements PhaseAwareReporter
940+
func (r *OTelReporter) PackageBuildPhaseFinished(pkg *Package, phase PackageBuildPhase, err error) {
941+
if r.tracer == nil {
942+
return
943+
}
944+
945+
r.mu.Lock()
946+
defer r.mu.Unlock()
947+
948+
pkgName := pkg.FullName()
949+
phaseKey := fmt.Sprintf("%s:%s", pkgName, phase)
950+
span, ok := r.phaseSpans[phaseKey]
951+
if !ok {
952+
log.WithField("package", pkgName).WithField("phase", phase).Warn("PackageBuildPhaseFinished called without corresponding PackageBuildPhaseStarted")
953+
return
954+
}
955+
956+
// Set error status if phase failed
957+
if err != nil {
958+
span.RecordError(err)
959+
span.SetStatus(codes.Error, err.Error())
960+
} else {
961+
span.SetStatus(codes.Ok, "phase completed successfully")
962+
}
963+
964+
// End span
965+
span.End()
966+
967+
// Clean up
968+
delete(r.phaseSpans, phaseKey)
969+
}
970+
904971
// addGitHubAttributes adds GitHub Actions context attributes to the span
905972
func (r *OTelReporter) addGitHubAttributes(span trace.Span) {
906973
// Check if running in GitHub Actions

0 commit comments

Comments
 (0)