Skip to content

Commit 5313929

Browse files
committed
feat: OpenTelemetry trace export (closes #362)
Add opt-in OpenTelemetry trace export for waza eval runs. Span hierarchy is eval → task → turn → tool_call/model_call, with attributes following the OpenTelemetry GenAI semantic conventions (gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.tool.name, …). Off by default — when --otel-exporter is not set the OTel SDK is not initialized and there is no runtime overhead. Three exporters are supported: --otel-exporter otlp --otel-endpoint host:port [--otel-headers …] --otel-exporter stdout --otel-exporter file --otel-file path Default redaction strips prompt/tool-arg/tool-result/completion content; only sha256 and byte length are emitted. Pass --otel-include-payloads to record full content for private debugging. The implementation is a thin wrapper around the existing event emission in internal/orchestration/runner.go; nothing about the runner's control flow changed. internal/telemetry encapsulates Provider/Config/spans and exposes no-op tracers when disabled, so callers can always wrap work in spans without conditionals. Tests cover span hierarchy, GenAI attribute names, redaction behavior, header parsing, and the disabled-provider path without requiring a live backend. Adds a docs/guides/otel.mdx walkthrough with a local OTel Collector example and updates the CLI reference + README. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5af9038 commit 5313929

14 files changed

Lines changed: 1164 additions & 5 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,11 @@ Run an evaluation benchmark from a spec file.
331331
| `--skip-graders` | | Skip grading (execution only); grade later with `waza grade` |
332332
| `--keep-workspace` | | Preserve temp workspaces after execution for debugging |
333333
| `--auto-file-issue` | | Auto-file or update a GitHub issue for failing runs (requires `gh` and `GITHUB_REPOSITORY`) |
334+
| `--otel-exporter` | | Export OpenTelemetry traces using `otlp`, `stdout`, or `file`. Off by default. See [OpenTelemetry Tracing](https://microsoft.github.io/waza/guides/otel/). |
335+
| `--otel-endpoint` | | OTLP endpoint (host:port or URL); only used with `--otel-exporter=otlp` |
336+
| `--otel-headers` | | Comma-separated `key=value` OTLP headers (e.g. for auth) |
337+
| `--otel-file` | | File path for span JSON when `--otel-exporter=file` |
338+
| `--otel-include-payloads` | | Include prompt/tool-arg/tool-result/completion content in spans (default: redacted to `sha256`+length) |
334339

335340
**Result Caching**
336341

cmd/waza/cmd_run.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/microsoft/waza/internal/reporting"
3838
"github.com/microsoft/waza/internal/session"
3939
"github.com/microsoft/waza/internal/storage"
40+
"github.com/microsoft/waza/internal/telemetry"
4041
"github.com/microsoft/waza/internal/trigger"
4142
"github.com/microsoft/waza/internal/utils"
4243
"github.com/microsoft/waza/internal/workspace"
@@ -76,6 +77,18 @@ var (
7677
keepWorkspace bool
7778
autoFileIssue bool
7879

80+
otelExporter string
81+
otelEndpoint string
82+
otelHeaders string
83+
otelFile string
84+
otelIncludePayloads bool
85+
86+
// runTelemetry holds the configured OpenTelemetry provider for the
87+
// current `waza run` invocation. It is initialized in runCommandE and
88+
// passed to every constructed runner via WithTelemetry. nil when
89+
// telemetry is disabled.
90+
runTelemetry *telemetry.Provider
91+
7992
// newCopilotClientFn allows you to override the client used by the copilot engine, for this command.
8093
newCopilotClientFn func(clientOptions *copilot.ClientOptions) execution.CopilotClient
8194

@@ -157,6 +170,13 @@ You can also specify a skill name to run its eval:
157170
cmd.Flags().BoolVar(&keepWorkspace, "keep-workspace", false, "Preserve temp workspace directories after execution for debugging")
158171
cmd.Flags().BoolVar(&autoFileIssue, "auto-file-issue", false, "Auto-file or update a GitHub issue for failing runs (requires gh and GITHUB_REPOSITORY)")
159172

173+
// OpenTelemetry trace export (off by default).
174+
cmd.Flags().StringVar(&otelExporter, "otel-exporter", "", "Export OpenTelemetry traces using exporter: otlp|stdout|file (default: disabled)")
175+
cmd.Flags().StringVar(&otelEndpoint, "otel-endpoint", "", "OTLP endpoint (host:port or URL); only used with --otel-exporter=otlp")
176+
cmd.Flags().StringVar(&otelHeaders, "otel-headers", "", "Comma-separated key=value OTLP headers (e.g. for auth)")
177+
cmd.Flags().StringVar(&otelFile, "otel-file", "", "File path for span JSON when --otel-exporter=file")
178+
cmd.Flags().BoolVar(&otelIncludePayloads, "otel-include-payloads", false, "Include prompt/tool-arg/tool-result/completion content in spans (default: redacted, only sha256+length emitted)")
179+
160180
return cmd
161181
}
162182

@@ -207,6 +227,42 @@ func runCommandE(cmd *cobra.Command, args []string) error {
207227
return fmt.Errorf("--trials must be at least 1")
208228
}
209229

230+
// Initialize OpenTelemetry trace export if requested. The provider
231+
// outlives every per-model engine and runner so spans share one root
232+
// flush at process exit.
233+
otelHeadersMap, err := telemetry.ParseHeaders(otelHeaders)
234+
if err != nil {
235+
return fmt.Errorf("--otel-headers: %w", err)
236+
}
237+
telemetryCfg := telemetry.Config{
238+
Exporter: telemetry.ExporterKind(otelExporter),
239+
Endpoint: otelEndpoint,
240+
Headers: otelHeadersMap,
241+
FilePath: otelFile,
242+
IncludePayloads: otelIncludePayloads,
243+
ServiceName: "waza",
244+
ServiceVersion: version,
245+
}
246+
tp, err := telemetry.New(cmd.Context(), telemetryCfg)
247+
if err != nil {
248+
return fmt.Errorf("init opentelemetry: %w", err)
249+
}
250+
runTelemetry = tp
251+
if tp.Enabled() {
252+
// Register globally so libraries that read from the global tracer
253+
// provider (e.g. instrumented HTTP clients) can attach to the same
254+
// trace. Best-effort — engines without OTel support are unaffected.
255+
tp.SetGlobal()
256+
}
257+
defer func() {
258+
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
259+
defer cancel()
260+
if err := tp.Shutdown(shutdownCtx); err != nil {
261+
slog.Warn("opentelemetry shutdown failed", "error", err)
262+
}
263+
runTelemetry = nil
264+
}()
265+
210266
// Apply config defaults for output-dir when not explicitly set
211267
if outputDir == "" && !cmd.Flags().Changed("output-dir") && outputPath == "" {
212268
wd, _ := os.Getwd() //nolint:errcheck
@@ -715,6 +771,9 @@ func runSingleModel(cmd *cobra.Command, spec *models.EvalSpec, specPath string,
715771
if skipGradersFlag {
716772
runnerOpts = append(runnerOpts, orchestration.WithSkipGraders())
717773
}
774+
if runTelemetry != nil && runTelemetry.Enabled() {
775+
runnerOpts = append(runnerOpts, orchestration.WithTelemetry(runTelemetry))
776+
}
718777
runner := newBenchmarkRunner(cfg, engine, runnerOpts...)
719778

720779
// Setup session logger if enabled

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ require (
5353
github.com/buger/goterm v1.0.4 // indirect
5454
github.com/buger/jsonparser v1.1.2 // indirect
5555
github.com/catppuccin/go v0.3.0 // indirect
56+
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
5657
github.com/cespare/xxhash/v2 v2.3.0 // indirect
5758
github.com/charmbracelet/bubbles v1.0.0 // indirect
5859
github.com/charmbracelet/bubbletea v1.3.10 // indirect
@@ -81,6 +82,7 @@ require (
8182
github.com/google/jsonschema-go v0.4.2 // indirect
8283
github.com/google/uuid v1.6.0 // indirect
8384
github.com/gorilla/css v1.0.1 // indirect
85+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
8486
github.com/inconshreveable/mousetrap v1.1.0 // indirect
8587
github.com/invopop/jsonschema v0.13.0 // indirect
8688
github.com/jmespath-community/go-jmespath v1.1.1 // indirect
@@ -116,9 +118,13 @@ require (
116118
github.com/yuin/goldmark-emoji v1.0.6 // indirect
117119
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
118120
go.opentelemetry.io/otel v1.43.0 // indirect
121+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
122+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
123+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
119124
go.opentelemetry.io/otel/metric v1.43.0 // indirect
120125
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
121126
go.opentelemetry.io/otel/trace v1.43.0 // indirect
127+
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
122128
go.uber.org/atomic v1.11.0 // indirect
123129
go.uber.org/multierr v1.11.0 // indirect
124130
golang.org/x/crypto v0.53.0 // indirect
@@ -128,6 +134,7 @@ require (
128134
golang.org/x/sys v0.46.0 // indirect
129135
golang.org/x/time v0.15.0 // indirect
130136
golang.org/x/tools v0.45.0 // indirect
137+
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
131138
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
132139
google.golang.org/grpc v1.80.0 // indirect
133140
google.golang.org/protobuf v1.36.11 // indirect

go.sum

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ
7373
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
7474
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
7575
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
76+
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
77+
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
7678
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
7779
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
7880
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
@@ -162,6 +164,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
162164
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
163165
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
164166
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
167+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
168+
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
165169
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
166170
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
167171
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
@@ -284,6 +288,12 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
284288
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
285289
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
286290
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
291+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
292+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
293+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
294+
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
295+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
296+
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
287297
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
288298
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
289299
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
@@ -292,6 +302,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
292302
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
293303
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
294304
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
305+
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
306+
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
295307
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
296308
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
297309
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -353,6 +365,8 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0
353365
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
354366
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
355367
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
368+
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
369+
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
356370
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
357371
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
358372
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=

internal/orchestration/runner.go

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/microsoft/waza/internal/hooks"
2323
"github.com/microsoft/waza/internal/models"
2424
"github.com/microsoft/waza/internal/responder"
25+
"github.com/microsoft/waza/internal/telemetry"
2526
"github.com/microsoft/waza/internal/template"
2627
"github.com/microsoft/waza/internal/transcript"
2728
"github.com/microsoft/waza/internal/utils"
@@ -72,6 +73,11 @@ type EvalRunner struct {
7273
listeners []ProgressListener
7374

7475
failureHandler *failures.Handler
76+
77+
// telemetry is the OpenTelemetry provider used to emit eval/task/turn
78+
// spans. Nil means tracing is off; the helpers in internal/telemetry
79+
// degrade to no-op tracers in that case.
80+
telemetry *telemetry.Provider
7581
}
7682

7783
// ProgressListener receives progress updates
@@ -145,6 +151,16 @@ func WithSkipGraders() RunnerOption {
145151
}
146152
}
147153

154+
// WithTelemetry attaches an OpenTelemetry provider so the runner emits
155+
// eval/task/turn/tool_call/model_call spans. Pass nil (or omit) to disable
156+
// tracing; in that case the runner's internal calls fall through the
157+
// no-op tracer and the runner behaves exactly as it did before.
158+
func WithTelemetry(p *telemetry.Provider) RunnerOption {
159+
return func(r *EvalRunner) {
160+
r.telemetry = p
161+
}
162+
}
163+
148164
// NewEvalRunner creates a new test runner. The caller owns the engine and is responsible for initializing and shutting it down as needed.
149165
func NewEvalRunner(cfg *config.EvalConfig, engine execution.AgentEngine, opts ...RunnerOption) *EvalRunner {
150166
r := &EvalRunner{
@@ -206,11 +222,22 @@ func (r *EvalRunner) RunBenchmark(ctx context.Context) (*models.EvaluationOutcom
206222

207223
spec := r.cfg.Spec()
208224

225+
// Open the root telemetry span for the whole eval. The span is a no-op
226+
// when telemetry is disabled, so this call is always safe.
227+
evalCtx, evalSpan := telemetry.StartEvalSpan(ctx, r.telemetry, telemetry.EvalInfo{
228+
Name: spec.Name,
229+
Skill: spec.SkillName,
230+
Engine: spec.Config.EngineType,
231+
Model: spec.Config.ModelID,
232+
RunID: fmt.Sprintf("run-%d", time.Now().Unix()),
233+
})
234+
defer evalSpan.End()
235+
209236
if spec.Baseline {
210-
return r.runBaselineComparison(ctx)
237+
return r.runBaselineComparison(evalCtx)
211238
}
212239

213-
return r.runNormalBenchmark(ctx)
240+
return r.runNormalBenchmark(evalCtx)
214241
}
215242

216243
// runNormalBenchmark executes a normal single-pass evaluation
@@ -904,6 +931,14 @@ func (r *EvalRunner) runConcurrent(ctx context.Context, testCases []*models.Test
904931
func (r *EvalRunner) runTest(ctx context.Context, tc *models.TestCase, testNum, totalTests int) (models.TestOutcome, bool) {
905932
spec := r.cfg.Spec()
906933

934+
// Open a per-task span. The span ends with the function via defer so
935+
// both the cached and uncached paths report timing to the tracer.
936+
taskCtx, taskSpan := telemetry.StartTaskSpan(ctx, r.telemetry, telemetry.TaskInfo{
937+
TestID: tc.TestID,
938+
DisplayName: tc.DisplayName,
939+
})
940+
defer taskSpan.End()
941+
907942
// Check cache if enabled
908943
if r.cache != nil {
909944
cacheKey, err := cache.CacheKey(spec, tc, r.cfg.FixtureDir())
@@ -913,7 +948,7 @@ func (r *EvalRunner) runTest(ctx context.Context, tc *models.TestCase, testNum,
913948
return *cachedOutcome, true
914949
}
915950
// Run the test and cache the result
916-
outcome := r.runTestUncached(ctx, tc, testNum, totalTests)
951+
outcome := r.runTestUncached(taskCtx, tc, testNum, totalTests)
917952
// Store in cache and log any failures
918953
if err := r.cache.Put(cacheKey, &outcome); err != nil {
919954
fmt.Fprintf(os.Stderr, "[WARN] Failed to write cache for test %q: %v\n", tc.DisplayName, err)
@@ -923,7 +958,7 @@ func (r *EvalRunner) runTest(ctx context.Context, tc *models.TestCase, testNum,
923958
}
924959

925960
// No cache or cache key generation failed
926-
return r.runTestUncached(ctx, tc, testNum, totalTests), false
961+
return r.runTestUncached(taskCtx, tc, testNum, totalTests), false
927962
}
928963

929964
func (r *EvalRunner) writeTaskTranscript(tc *models.TestCase, outcome models.TestOutcome, startTime time.Time) {
@@ -1052,6 +1087,18 @@ func (r *EvalRunner) executeRun(ctx context.Context, tc *models.TestCase, runNum
10521087
})
10531088
}
10541089

1090+
// Open the per-turn span around the initial Execute call. Follow-ups
1091+
// (executeFollowUps / executeResponderLoop) are children of the task
1092+
// span, not this turn — they open their own turn spans below — to keep
1093+
// the hierarchy flat: task → turn(initial) + turn(follow-up) + ...
1094+
turnCtx, turnSpan := telemetry.StartTurnSpan(ctx, r.telemetry, telemetry.TurnInfo{
1095+
Number: runNum,
1096+
Kind: "initial",
1097+
Model: r.cfg.Spec().Config.ModelID,
1098+
Prompt: req.Message,
1099+
})
1100+
defer turnSpan.End()
1101+
10551102
// Emit agent prompt event before execution
10561103
if r.verbose {
10571104
r.notifyProgress(ProgressEvent{
@@ -1071,7 +1118,7 @@ func (r *EvalRunner) executeRun(ctx context.Context, tc *models.TestCase, runNum
10711118
ErrorMsg: err.Error(),
10721119
})
10731120
}
1074-
execCtx, cancelExec := context.WithTimeout(ctx, timeout)
1121+
execCtx, cancelExec := context.WithTimeout(turnCtx, timeout)
10751122
resp, err := r.engine.Execute(execCtx, req)
10761123
cancelExec()
10771124
if err != nil {
@@ -1083,6 +1130,11 @@ func (r *EvalRunner) executeRun(ctx context.Context, tc *models.TestCase, runNum
10831130
})
10841131
}
10851132

1133+
// Emit child tool_call/model_call spans from the engine response. These
1134+
// are after-the-fact records (waza only learns about them when Execute
1135+
// returns) so the spans collapse to a single timestamp under the turn.
1136+
emitChildSpans(turnCtx, r.telemetry, turnSpan, resp, r.cfg.Spec().Config.ModelID)
1137+
10861138
// Emit agent response event after execution
10871139
if r.verbose {
10881140
r.notifyProgress(ProgressEvent{

0 commit comments

Comments
 (0)