From 5ddc04301251287914344f67d148a6d3998a851b Mon Sep 17 00:00:00 2001 From: Yu Yi Date: Thu, 18 Jun 2026 10:57:02 -0400 Subject: [PATCH] tracing: OpenTelemetry spans for the agent loop (closes #358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a dependency-isolated `tracing/` package that turns the loop's existing `Event` stream into an OpenTelemetry span tree — one trace per prompt: glue.agent.run -> glue.agent.turn -> glue.llm.generate / glue.tool.. Spans carry GenAI-convention attributes (model, provider, finish reason, token usage) plus glue.* detail (session id, tool name/call-id/is_error, turn index); failures flag the root span so failed traces filter at the top. A `Recorder` consumes the event stream and maintains the span tree (tool-call-id keyed map so parallel tool calls close correctly), closing open spans on loop end / error / restart so early returns don't leak spans. The `loop` and core `glue` packages stay free of OpenTelemetry imports — all otel deps live in `tracing/` and `cmd/glue`. Provider setup reads the standard OTEL_* environment (OTEL_TRACES_EXPORTER otlp|stdout|none, OTEL_EXPORTER_OTLP_*, OTEL_SERVICE_NAME) and is off by default (zero overhead until an exporter is configured). Wired into `glue run`, the TUI, and `glue goal` — the goal loop gains a `GoalSpec.OnSession` seam so the host can observe its planner/maker/checker sessions without the core loop depending on any backend. Borrowed from Vercel's Eve framework. ADR-0018 + docs/tracing.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 15 ++ cmd/glue/goalcmd.go | 7 + cmd/glue/main.go | 13 ++ cmd/glue/run.go | 2 + cmd/glue/tui/tui.go | 2 + docs/adr/0018-otel-tracing.md | 92 ++++++++++ docs/tracing.md | 110 ++++++++++++ go.mod | 33 +++- go.sum | 80 +++++++-- goal.go | 19 ++ tracing/doc.go | 24 +++ tracing/provider.go | 123 +++++++++++++ tracing/provider_test.go | 85 +++++++++ tracing/recorder.go | 318 ++++++++++++++++++++++++++++++++++ tracing/recorder_test.go | 239 +++++++++++++++++++++++++ 15 files changed, 1135 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0018-otel-tracing.md create mode 100644 docs/tracing.md create mode 100644 tracing/doc.go create mode 100644 tracing/provider.go create mode 100644 tracing/provider_test.go create mode 100644 tracing/recorder.go create mode 100644 tracing/recorder_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e793c19..d25fb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,21 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **OpenTelemetry tracing for the agent loop (`tracing`).** Every run + now emits an OTLP trace — `glue.agent.run` → `glue.agent.turn` → + `glue.llm.generate` / `glue.tool.` — with GenAI-convention + attributes (model, provider, finish reason, token usage) and tool + error flags. A `Recorder` translates the existing loop `Event` stream + into spans, so the `loop` and core `glue` packages stay free of + OpenTelemetry imports. Configured from the standard `OTEL_*` + environment and off by default (zero overhead until an exporter is + set); `OTEL_TRACES_EXPORTER=stdout` gives a zero-backend local view. + Wired into `glue run`, the TUI, and `glue goal` (which traces its + planner/maker/checker sessions via the new `GoalSpec.OnSession` seam). + Borrowed from Vercel's Eve framework; see + [ADR-0018](docs/adr/0018-otel-tracing.md) and + [docs/tracing.md](docs/tracing.md). (#358) + - **TUI: `/` picker shows every command; `@` picker gains scroll indicators (`cmd/glue/tui`).** The slash-command popup previously reused the file picker's 8-row scroll window with no indicator, so a diff --git a/cmd/glue/goalcmd.go b/cmd/glue/goalcmd.go index 06e38f6..20b894c 100644 --- a/cmd/glue/goalcmd.go +++ b/cmd/glue/goalcmd.go @@ -12,6 +12,7 @@ import ( "github.com/erain/glue" "github.com/erain/glue/cmd/glue/worktree" filestore "github.com/erain/glue/stores/file" + "github.com/erain/glue/tracing" ) // Exit codes for `glue goal`, so cron/CI schedulers can branch on the @@ -174,6 +175,12 @@ func goalCommand(ctx context.Context, args []string, stdin io.Reader, stdout, st } } + // Trace every planner/maker/checker session so unattended goal runs + // are observable end-to-end. A no-op unless OTEL_* is configured. + spec.OnSession = func(s *glue.Session) { + s.Subscribe(tracing.NewSessionRecorder(s.ID()).Handle) + } + spec.Emit = func(ev glue.GoalEvent) { switch ev.Type { case glue.GoalEventPlan: diff --git a/cmd/glue/main.go b/cmd/glue/main.go index a4e7a2a..9b66ee0 100644 --- a/cmd/glue/main.go +++ b/cmd/glue/main.go @@ -28,6 +28,7 @@ import ( "github.com/erain/glue/providers" filestore "github.com/erain/glue/stores/file" toolscoding "github.com/erain/glue/tools/coding" + "github.com/erain/glue/tracing" // Register the shipped providers so they resolve through the // providers registry by name (--provider). Importing for side @@ -108,6 +109,18 @@ func runCLIWithDeps(ctx context.Context, args []string, stdin io.Reader, stdout return 0 } + // Configure OpenTelemetry tracing from the OTEL_* environment. A no-op + // unless an exporter is configured, so it costs nothing by default. + if shutdown, err := tracing.Setup(ctx); err != nil { + fmt.Fprintf(stderr, "glue: tracing disabled: %v\n", err) + } else if shutdown != nil { + defer func() { + flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = shutdown(flushCtx) + }() + } + switch args[0] { case "run": if err := runCommand(ctx, args[1:], stdin, stdout, stderr, newProvider); err != nil { diff --git a/cmd/glue/run.go b/cmd/glue/run.go index ef93f15..725854a 100644 --- a/cmd/glue/run.go +++ b/cmd/glue/run.go @@ -20,6 +20,7 @@ import ( "github.com/erain/glue/cmd/glue/tui" filestore "github.com/erain/glue/stores/file" toolscoding "github.com/erain/glue/tools/coding" + "github.com/erain/glue/tracing" // Register the shipped providers so they resolve through the // providers registry by name (--provider). Importing for side ) @@ -242,6 +243,7 @@ func runCommand(ctx context.Context, args []string, stdin io.Reader, stdout io.W if err != nil { return err } + defer session.Subscribe(tracing.NewSessionRecorder(session.ID()).Handle)() if jsonMode { return runOneShotJSON(ctx, session, effectivePrompt, *id, providerName, effectiveModel, stdout, *showUsage, usagePricing, stderr) diff --git a/cmd/glue/tui/tui.go b/cmd/glue/tui/tui.go index 7672ea5..4f54d48 100644 --- a/cmd/glue/tui/tui.go +++ b/cmd/glue/tui/tui.go @@ -21,6 +21,7 @@ import ( "github.com/erain/glue" "github.com/erain/glue/cmd/glue/atmentions" + "github.com/erain/glue/tracing" ) // Config wires the TUI to a glue agent. Provider/Model/WorkDir are @@ -1239,6 +1240,7 @@ func (m *Model) startTurn(prompt string) tea.Cmd { go func() { defer cancel() + defer session.Subscribe(tracing.NewSessionRecorder(session.ID()).Handle)() unsubscribe := session.Subscribe(func(e glue.Event) { switch e.Type { case glue.EventTextDelta: diff --git a/docs/adr/0018-otel-tracing.md b/docs/adr/0018-otel-tracing.md new file mode 100644 index 0000000..3549ec1 --- /dev/null +++ b/docs/adr/0018-otel-tracing.md @@ -0,0 +1,92 @@ +# ADR-0018: OpenTelemetry Tracing for the Agent Loop + +## Status + +Accepted (2026-06-18). + +## Context + +Glue had no observability: a run was a black box once it left the +terminal. Diagnosing a slow turn, an unexpected tool failure, or a +runaway goal loop meant re-reading stdout or the JSON event stream by +hand. There was no way to answer "where did the latency go?", "how many +tokens did this objective burn, per iteration?", or "which tool errored +in last night's unattended run?". + +Research into Vercel's **Eve** agent framework (June 2026) flagged +first-class tracing as the cleanest idea to borrow: Eve emits a span per +model call and per tool invocation over standard OpenTelemetry, so runs +drop into Jaeger / Honeycomb / Datadog with no bespoke tooling. Glue +already has the ideal substrate — `loop.Run` emits a strictly-ordered +`Event` stream (loop/turn/message/tool start-end, error), and +`Session.Subscribe(func(Event))` is a public, additive, fan-out sink. +Nothing in the hot path needed to change to observe it. + +## Decision + +Add a dependency-isolated `tracing/` package that translates the loop +event stream into an OpenTelemetry span tree, one trace per prompt: + +``` +glue.agent.run (one trace per loop.Run / prompt) +├── glue.agent.turn (one per assistant turn) +│ ├── glue.llm.generate (the provider call; model, tokens, finish reason) +│ └── glue.tool. (each tool call; is_error) +└── ... +``` + +Key choices: + +- **Event-stream translation, not loop instrumentation.** A `Recorder` + consumes the existing `Event` stream and maintains the span tree + (root/turn/message + a map keyed by tool-call id so parallel tool + calls close against the right span). The `loop` and root `glue` + packages stay free of OpenTelemetry imports; all otel deps live in + `tracing/` and `cmd/glue`. This mirrors how `WithStreamWriter` / + `WithToolLogger` already attach to the event stream. + +- **GenAI semantic conventions.** Spans carry `gen_ai.*` attributes + (request/response model, `gen_ai.system` = provider, finish reason, + input/output/total tokens from `Message.Usage`) plus `glue.*` detail + (session id, tool name/call-id/is_error, turn index), so existing LLM + dashboards light up without remapping. + +- **Standard env contract, off by default.** `NewTracerProvider` / + `Setup` read the standard `OTEL_*` environment + (`OTEL_TRACES_EXPORTER` = `otlp` | `stdout` | `none`, + `OTEL_EXPORTER_OTLP_*`, `OTEL_SERVICE_NAME`). With nothing configured + the whole path is a no-op (`Enabled()` is false, `NewSessionRecorder` + returns a nil-tracer recorder), so there is zero overhead and zero + config until a backend is wired up. + +- **Resilient span lifecycle.** The recorder closes any open spans on + `EventLoopEnd`, on a fresh `EventLoopStart` (a prior crashed run can't + leak spans into the next prompt), and on `EventError` (which also + flags the root span so failed traces are filterable at the top). This + matters because the loop returns early on cancellation, provider + error, and max-turns without emitting the matching end events. + +- **Wired at the host edge.** `cmd/glue` calls `tracing.Setup` once in + `main` (with a flush-on-exit shutdown) and attaches a recorder via + `Session.Subscribe` in the `run`, TUI, and `goal` paths. The goal loop + gains a `GoalSpec.OnSession` seam so the host can observe its internal + planner/maker/checker sessions without the core loop depending on any + tracing backend. + +## Consequences + +- glue runs are now observable end-to-end against any OTLP backend with + a single env var; `OTEL_TRACES_EXPORTER=stdout` gives a zero-backend + local view. +- The module now depends on the OpenTelemetry SDK and OTLP/stdout + exporters. They are only imported by `tracing/` and `cmd/glue`; the + embeddable `glue` / `loop` libraries remain otel-free at the type + level (the `glue` package references tracing only through the optional + `GoalSpec.OnSession` hook, which is an ordinary `func(*Session)`). +- Provider HTTP-level spans are **not** yet nested under + `glue.llm.generate` — context is not threaded into `Provider.Stream`. + The event-stream design records accurate wall-clock per call; deeper + propagation (and instrumenting the provider HTTP clients) is a + follow-up. +- The daemon (`serve`) path is not yet traced; it can subscribe a + recorder per session the same way when prioritized. diff --git a/docs/tracing.md b/docs/tracing.md new file mode 100644 index 0000000..cf21675 --- /dev/null +++ b/docs/tracing.md @@ -0,0 +1,110 @@ +# Tracing glue runs with OpenTelemetry + +Glue emits OpenTelemetry traces for every agent run, so you can see where +latency, tokens, and tool failures actually go — in Jaeger, Honeycomb, +Datadog, Grafana Tempo, or any OTLP-compatible backend. + +Tracing is **off until you configure an exporter**, so there is zero +overhead by default. See [ADR-0018](adr/0018-otel-tracing.md) for the +design. + +## The span tree + +Each prompt (one `loop.Run`) is one trace: + +``` +glue.agent.run root span — one per prompt +├── glue.agent.turn one per assistant turn +│ ├── glue.llm.generate the provider call +│ └── glue.tool. each tool invocation +└── glue.agent.turn + └── ... +``` + +Attributes follow the OpenTelemetry GenAI semantic conventions where +they apply, so existing LLM dashboards work unchanged: + +| Span | Attributes | +| --- | --- | +| `glue.agent.run` | `glue.session.id` | +| `glue.agent.turn` | `glue.turn.index` | +| `glue.llm.generate` | `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.system` (provider), `gen_ai.response.finish_reason`, `gen_ai.usage.input_tokens` / `output_tokens` / `total_tokens`, `glue.usage.cache_read_tokens` / `cache_write_tokens` | +| `glue.tool.` | `glue.tool.name`, `glue.tool.call_id`, `glue.tool.is_error` | + +A failed run flags both the deepest open span and the root span with an +`Error` status, so failed traces are easy to filter. + +## Enabling it + +Tracing reads the standard `OTEL_*` environment — no glue-specific +config. + +**Send to an OTLP backend** (Jaeger, Honeycomb, Datadog, Tempo, …): + +```sh +export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +export OTEL_SERVICE_NAME=glue # optional, defaults to "glue" +glue run --coding "fix the failing test" +``` + +Setting an endpoint is enough to enable the OTLP/HTTP exporter. All the +standard `OTEL_EXPORTER_OTLP_*` variables (headers, protocol, per-signal +endpoints) are honored. + +**Print spans locally** with no backend (handy for debugging): + +```sh +OTEL_TRACES_EXPORTER=stdout glue run --coding "..." +``` + +**Force it off** even if an endpoint is set: + +```sh +OTEL_TRACES_EXPORTER=none glue run ... +``` + +`glue goal` traces its planner, every maker, and every checker session, +which makes unattended goal loops observable end to end. + +## Embedding glue + +If you embed the `glue` library, wire tracing yourself: + +```go +import ( + "github.com/erain/glue" + "github.com/erain/glue/tracing" +) + +// Once at startup: configure the global tracer provider from OTEL_*. +shutdown, err := tracing.Setup(ctx) +if err == nil && shutdown != nil { + defer shutdown(context.Background()) // flush on exit +} + +// Per session: attach a recorder. No-op when tracing is disabled. +sess, _ := agent.Session(ctx, "my-session") +defer sess.Subscribe(tracing.NewSessionRecorder(sess.ID()).Handle)() +``` + +For the goal loop, set `GoalSpec.OnSession` to subscribe a recorder to +each internal session: + +```go +spec.OnSession = func(s *glue.Session) { + s.Subscribe(tracing.NewSessionRecorder(s.ID()).Handle) +} +``` + +The `loop` and core `glue` packages carry no OpenTelemetry imports; all +of it lives in `tracing/`. + +## Not yet traced + +- **Provider HTTP spans** are not nested under `glue.llm.generate` — + span timing is accurate, but the underlying HTTP request is not a + child span yet. +- **The `serve` daemon** does not attach recorders to its sessions yet. + +Both are straightforward follow-ups on the same `Session.Subscribe` +seam. diff --git a/go.mod b/go.mod index 8472114..c6fe1aa 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,12 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - golang.org/x/term v0.36.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/trace v1.40.0 + golang.org/x/term v0.39.0 google.golang.org/genai v1.55.0 modernc.org/sqlite v1.50.1 ) @@ -15,11 +20,13 @@ require ( require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.9.3 // indirect - cloud.google.com/go/compute/metadata v0.5.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.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.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect @@ -31,13 +38,16 @@ require ( github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -54,13 +64,18 @@ require ( github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.36.0 // indirect - golang.org/x/net v0.38.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.30.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect - google.golang.org/grpc v1.66.2 // indirect - google.golang.org/protobuf v1.34.2 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 60bf345..e55bbd2 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,8 @@ cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= -cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= -cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= @@ -22,7 +22,11 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -52,6 +56,7 @@ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -63,6 +68,11 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -77,14 +87,16 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= @@ -98,6 +110,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -123,6 +137,7 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -137,6 +152,8 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= @@ -145,10 +162,32 @@ github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= @@ -163,8 +202,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -179,12 +218,12 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -193,6 +232,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genai v1.55.0 h1:iLHGk4Bj/IZ/GNNZb7hYqwSJMRBvqLeu2Hb6YQ+rYGw= @@ -200,15 +241,17 @@ google.golang.org/genai v1.55.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5g google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= -google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -218,10 +261,11 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/goal.go b/goal.go index b28a9db..052f7aa 100644 --- a/goal.go +++ b/goal.go @@ -82,6 +82,14 @@ type GoalSpec struct { // Emit, when set, receives progress events as the loop runs. Emit func(GoalEvent) + + // OnSession, when set, is called once for each internal session the + // loop opens (planner, every maker, every checker) right after it is + // created and before its first prompt. Hosts use it to attach + // session-scoped event handlers — e.g. an OpenTelemetry span recorder + // (see github.com/erain/glue/tracing) — so unattended goal runs are + // observable without the loop depending on any tracing backend. + OnSession func(*Session) } // GoalStatus is the terminal state of a goal loop. @@ -335,11 +343,20 @@ type goalVerdict struct { Summary string `json:"summary"` } +// notifyOnSession invokes spec.OnSession for s when a host registered one, +// so it can attach session-scoped observers (e.g. a tracing recorder). +func (spec GoalSpec) notifyOnSession(s *Session) { + if spec.OnSession != nil { + spec.OnSession(s) + } +} + func (a *Agent) planGoal(ctx context.Context, spec GoalSpec) ([]ChecklistItem, Usage, error) { sess, err := a.Session(ctx, spec.SessionPrefix+":plan") if err != nil { return nil, Usage{}, err } + spec.notifyOnSession(sess) opts := []PromptOption{WithJSONSchema(goalPlanSchema)} if spec.Model != "" { opts = append(opts, WithModel(spec.Model)) @@ -373,6 +390,7 @@ func (a *Agent) runGoalMaker(ctx context.Context, spec GoalSpec, iter int, check if err != nil { return Usage{}, err } + spec.notifyOnSession(sess) var opts []PromptOption if spec.Model != "" { opts = append(opts, WithModel(spec.Model)) @@ -400,6 +418,7 @@ func (a *Agent) runGoalChecker(ctx context.Context, spec GoalSpec, iter int, che if err != nil { return goalVerdict{}, Usage{}, err } + spec.notifyOnSession(sess) opts := []PromptOption{ WithJSONSchema(goalVerdictSchema), WithSystemPrompt(spec.CheckerSystemPrompt), diff --git a/tracing/doc.go b/tracing/doc.go new file mode 100644 index 0000000..48e636e --- /dev/null +++ b/tracing/doc.go @@ -0,0 +1,24 @@ +// Package tracing turns the glue agent loop's event stream into +// OpenTelemetry spans. +// +// The loop ([github.com/erain/glue/loop.Run]) already emits a structured, +// strictly-ordered [loop.Event] stream — loop start/end, turn start/end, +// message start/end, tool start/end, and error. A [Recorder] subscribes to +// that stream (via [github.com/erain/glue.Session.Subscribe] or any other +// aux event sink) and emits a span tree per prompt: +// +// glue.agent.run (one trace per prompt / loop.Run) +// ├── glue.agent.turn (one per assistant turn) +// │ ├── glue.llm.generate (the provider call) +// │ └── glue.tool. (each tool invocation) +// └── ... +// +// Spans carry GenAI-flavoured attributes (model, provider, finish reason, +// token usage) so traces drop straight into Jaeger, Honeycomb, Datadog, or +// any OTLP backend. +// +// The core loop and the root glue package stay free of OpenTelemetry +// imports: all otel dependencies live here and in cmd/glue. When no +// exporter is configured (see [NewTracerProvider]) the whole path is a +// no-op, so tracing costs nothing until a backend is wired up. +package tracing diff --git a/tracing/provider.go b/tracing/provider.go new file mode 100644 index 0000000..b15b5ef --- /dev/null +++ b/tracing/provider.go @@ -0,0 +1,123 @@ +package tracing + +import ( + "context" + "os" + "strings" + "sync/atomic" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// DefaultServiceName tags spans whose service name is not overridden by +// OTEL_SERVICE_NAME. +const DefaultServiceName = "glue" + +// ScopeName is the instrumentation scope (otel "tracer name") for spans +// emitted by glue. Backends group spans by this name. +const ScopeName = "github.com/erain/glue" + +// globalEnabled records whether Setup installed a real exporter, so +// session helpers can cheaply skip span work when tracing is off. +var globalEnabled atomic.Bool + +// Enabled reports whether tracing was configured by a successful Setup. +func Enabled() bool { return globalEnabled.Load() } + +// Setup builds a tracer provider from the OTEL_* environment and, when one +// is configured, registers it as the global OpenTelemetry provider and +// marks tracing enabled. It returns a shutdown func that flushes buffered +// spans, or a nil shutdown when tracing is not configured (the caller +// should guard against nil). See [NewTracerProvider] for the env contract. +func Setup(ctx context.Context) (shutdown func(context.Context) error, err error) { + tp, err := NewTracerProvider(ctx) + if err != nil || tp == nil { + return nil, err + } + otel.SetTracerProvider(tp) + globalEnabled.Store(true) + return tp.Shutdown, nil +} + +// NewTracerProvider builds an OpenTelemetry tracer provider from the +// standard OTEL_* environment, or returns (nil, nil) when tracing is not +// configured so the caller can skip wiring entirely. +// +// Exporter selection (OpenTelemetry conventions): +// +// OTEL_TRACES_EXPORTER=otlp OTLP/HTTP exporter (the default when an +// OTEL_EXPORTER_OTLP[_TRACES]_ENDPOINT is set) +// OTEL_TRACES_EXPORTER=stdout pretty-print spans to stderr (local debug) +// OTEL_TRACES_EXPORTER=none disabled (also the default when nothing is set) +// +// The OTLP exporter honours all standard OTEL_EXPORTER_OTLP_* variables +// (endpoint, headers, protocol) so it points at Jaeger/Honeycomb/Datadog +// without glue-specific configuration. Service name comes from +// OTEL_SERVICE_NAME (default "glue"). +// +// The returned provider is NOT registered globally; the caller decides +// whether to call otel.SetTracerProvider and is responsible for calling +// Shutdown to flush batched spans on exit. +func NewTracerProvider(ctx context.Context) (*sdktrace.TracerProvider, error) { + exp, err := newExporter(ctx, exporterKind()) + if err != nil || exp == nil { + return nil, err + } + + res, err := resource.New(ctx, + resource.WithFromEnv(), + resource.WithTelemetrySDK(), + resource.WithAttributes(attribute.String("service.name", serviceName())), + ) + if err != nil { + // A partial resource (e.g. an unparseable OTEL_RESOURCE_ATTRIBUTES) + // should not defeat tracing entirely; fall back to a minimal one. + res = resource.NewSchemaless(attribute.String("service.name", serviceName())) + } + + return sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(res), + ), nil +} + +func newExporter(ctx context.Context, kind string) (sdktrace.SpanExporter, error) { + switch kind { + case "none", "": + return nil, nil + case "stdout", "console": + return stdouttrace.New(stdouttrace.WithWriter(os.Stderr), stdouttrace.WithPrettyPrint()) + default: // "otlp" and anything else maps to the standard OTLP/HTTP exporter + return otlptracehttp.New(ctx) + } +} + +// exporterKind resolves the configured exporter, honouring an explicit +// OTEL_TRACES_EXPORTER and otherwise enabling OTLP whenever an endpoint is +// present. Unset with no endpoint means tracing is off. +func exporterKind() string { + if v := strings.ToLower(strings.TrimSpace(os.Getenv("OTEL_TRACES_EXPORTER"))); v != "" { + return v + } + if endpointConfigured() { + return "otlp" + } + return "none" +} + +func endpointConfigured() bool { + return os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") != "" || + os.Getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") != "" +} + +func serviceName() string { + if v := strings.TrimSpace(os.Getenv("OTEL_SERVICE_NAME")); v != "" { + return v + } + return DefaultServiceName +} diff --git a/tracing/provider_test.go b/tracing/provider_test.go new file mode 100644 index 0000000..416afc3 --- /dev/null +++ b/tracing/provider_test.go @@ -0,0 +1,85 @@ +package tracing + +import ( + "context" + "testing" +) + +func TestExporterKind(t *testing.T) { + cases := []struct { + name string + exporter string + endpoint string + tracesEndpoint string + want string + }{ + {name: "unset is off", want: "none"}, + {name: "explicit none", exporter: "none", want: "none"}, + {name: "explicit stdout", exporter: "stdout", want: "stdout"}, + {name: "explicit otlp", exporter: "otlp", want: "otlp"}, + {name: "endpoint implies otlp", endpoint: "http://localhost:4318", want: "otlp"}, + {name: "traces endpoint implies otlp", tracesEndpoint: "http://localhost:4318/v1/traces", want: "otlp"}, + {name: "explicit beats endpoint", exporter: "stdout", endpoint: "http://localhost:4318", want: "stdout"}, + {name: "case-insensitive", exporter: "STDOUT", want: "stdout"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", tc.exporter) + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", tc.endpoint) + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", tc.tracesEndpoint) + if got := exporterKind(); got != tc.want { + t.Errorf("exporterKind() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestServiceName(t *testing.T) { + t.Setenv("OTEL_SERVICE_NAME", "") + if got := serviceName(); got != DefaultServiceName { + t.Errorf("default service name = %q, want %q", got, DefaultServiceName) + } + t.Setenv("OTEL_SERVICE_NAME", "peggy") + if got := serviceName(); got != "peggy" { + t.Errorf("service name = %q, want peggy", got) + } +} + +func TestNewTracerProviderDisabledByDefault(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "") + t.Setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "") + t.Setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") + tp, err := NewTracerProvider(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tp != nil { + t.Fatalf("expected nil provider when tracing is unconfigured, got %v", tp) + } +} + +func TestNewTracerProviderStdout(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "stdout") + tp, err := NewTracerProvider(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tp == nil { + t.Fatal("expected a provider for the stdout exporter") + } + _ = tp.Shutdown(context.Background()) +} + +func TestSetupDisabledReturnsNilShutdown(t *testing.T) { + t.Setenv("OTEL_TRACES_EXPORTER", "none") + shutdown, err := Setup(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if shutdown != nil { + t.Error("expected nil shutdown when tracing is disabled") + } + if Enabled() { + t.Error("Enabled() should be false when Setup did not configure an exporter") + } +} diff --git a/tracing/recorder.go b/tracing/recorder.go new file mode 100644 index 0000000..19ffd73 --- /dev/null +++ b/tracing/recorder.go @@ -0,0 +1,318 @@ +package tracing + +import ( + "context" + "sync" + + "github.com/erain/glue/loop" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Span names. Kept stable and namespaced so dashboards can group by them. +const ( + spanRun = "glue.agent.run" + spanTurn = "glue.agent.turn" + spanGenerate = "glue.llm.generate" + spanToolPrefix = "glue.tool." +) + +// Attribute keys. The gen_ai.* keys follow the OpenTelemetry GenAI +// semantic conventions so traces interoperate with existing LLM +// dashboards; glue.* keys carry framework-specific detail. +const ( + attrSystem = attribute.Key("gen_ai.system") + attrRequestModel = attribute.Key("gen_ai.request.model") + attrResponseModel = attribute.Key("gen_ai.response.model") + attrFinishReason = attribute.Key("gen_ai.response.finish_reason") + attrInputTokens = attribute.Key("gen_ai.usage.input_tokens") + attrOutputTokens = attribute.Key("gen_ai.usage.output_tokens") + attrTotalTokens = attribute.Key("gen_ai.usage.total_tokens") + + attrCacheReadTokens = attribute.Key("glue.usage.cache_read_tokens") + attrCacheWriteTokens = attribute.Key("glue.usage.cache_write_tokens") + attrSessionID = attribute.Key("glue.session.id") + attrToolName = attribute.Key("glue.tool.name") + attrToolCallID = attribute.Key("glue.tool.call_id") + attrToolError = attribute.Key("glue.tool.is_error") + attrTurnIndex = attribute.Key("glue.turn.index") +) + +// Recorder translates a single session's loop event stream into +// OpenTelemetry spans. Pass [Recorder.Handle] to +// [github.com/erain/glue.Session.Subscribe]; each prompt (one loop.Run, +// bracketed by EventLoopStart/EventLoopEnd) becomes its own trace. +// +// A Recorder is safe for the serial event delivery a single session +// provides (prompts are serialized by the session run lock) and guards +// its state with a mutex regardless. Do not share one Recorder across +// sessions whose prompts can overlap — give each session its own. +type Recorder struct { + tracer trace.Tracer + base context.Context + attrs []attribute.KeyValue + + mu sync.Mutex + root trace.Span + rootCtx context.Context + turn trace.Span + turnCtx context.Context + msg trace.Span + tools map[string]trace.Span + turnNum int +} + +// Option configures a [Recorder]. +type Option func(*Recorder) + +// WithBaseContext sets the parent context the root span is started from, +// so loop traces nest under an existing caller span. Defaults to +// context.Background (each prompt roots its own trace). +func WithBaseContext(ctx context.Context) Option { + return func(r *Recorder) { + if ctx != nil { + r.base = ctx + } + } +} + +// WithSessionID tags every span with the originating session id. +func WithSessionID(id string) Option { + return func(r *Recorder) { + if id != "" { + r.attrs = append(r.attrs, attrSessionID.String(id)) + } + } +} + +// WithAttributes adds static attributes to every root span. +func WithAttributes(attrs ...attribute.KeyValue) Option { + return func(r *Recorder) { r.attrs = append(r.attrs, attrs...) } +} + +// NewRecorder builds a Recorder that emits spans on tracer. A nil tracer +// yields a Recorder whose Handle is a no-op, so callers can wire tracing +// unconditionally and let an unconfigured backend disable it. +func NewRecorder(tracer trace.Tracer, opts ...Option) *Recorder { + r := &Recorder{tracer: tracer, base: context.Background(), tools: map[string]trace.Span{}} + for _, opt := range opts { + opt(r) + } + return r +} + +// NewSessionRecorder returns a Recorder bound to the global tracer for the +// given session, or a no-op Recorder when tracing is disabled (see +// [Enabled]). Wire it unconditionally: +// +// rec := tracing.NewSessionRecorder(session.ID()) +// defer session.Subscribe(rec.Handle)() +func NewSessionRecorder(sessionID string) *Recorder { + if !Enabled() { + return NewRecorder(nil) + } + return NewRecorder(otel.Tracer(ScopeName), WithSessionID(sessionID)) +} + +// Handle consumes one loop event and updates the span tree. It matches the +// signature of a glue session event handler. +func (r *Recorder) Handle(e loop.Event) { + if r == nil || r.tracer == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + + switch e.Type { + case loop.EventLoopStart: + r.startRun() + case loop.EventTurnStart: + r.startTurn() + case loop.EventMessageStart: + r.startMessage(e) + case loop.EventMessageEnd: + r.endMessage(e) + case loop.EventToolStart: + r.startTool(e) + case loop.EventToolEnd: + r.endTool(e) + case loop.EventTurnEnd: + r.endTurn() + case loop.EventError: + r.recordError(e) + case loop.EventLoopEnd: + r.endRun() + } +} + +func (r *Recorder) startRun() { + // Defensive: a prior trace that never saw loop_end is closed out so a + // crashed/cancelled run cannot leak spans into the next prompt. + r.closeAll() + r.turnNum = 0 + r.rootCtx, r.root = r.tracer.Start(r.base, spanRun, trace.WithAttributes(r.attrs...)) +} + +func (r *Recorder) startTurn() { + parent := r.parentCtx(r.rootCtx) + r.turnCtx, r.turn = r.tracer.Start(parent, spanTurn, trace.WithAttributes(attrTurnIndex.Int(r.turnNum))) + r.turnNum++ +} + +func (r *Recorder) startMessage(e loop.Event) { + parent := r.parentCtx(r.turnCtx) + var attrs []attribute.KeyValue + if e.Message != nil { + if e.Message.Model != "" { + attrs = append(attrs, attrRequestModel.String(e.Message.Model)) + } + if e.Message.Provider != "" { + attrs = append(attrs, attrSystem.String(e.Message.Provider)) + } + } + _, r.msg = r.tracer.Start(parent, spanGenerate, trace.WithAttributes(attrs...)) +} + +func (r *Recorder) endMessage(e loop.Event) { + if r.msg == nil { + return + } + if m := e.Message; m != nil { + if m.Model != "" { + r.msg.SetAttributes(attrResponseModel.String(m.Model)) + } + if m.Provider != "" { + r.msg.SetAttributes(attrSystem.String(m.Provider)) + } + if m.StopReason != "" { + r.msg.SetAttributes(attrFinishReason.String(string(m.StopReason))) + } + if u := m.Usage; u != nil { + r.msg.SetAttributes( + attrInputTokens.Int64(u.InputTokens), + attrOutputTokens.Int64(u.OutputTokens), + attrTotalTokens.Int64(u.TotalTokens), + attrCacheReadTokens.Int64(u.CacheReadTokens), + attrCacheWriteTokens.Int64(u.CacheWriteTokens), + ) + } + } + r.msg.End() + r.msg = nil +} + +func (r *Recorder) startTool(e loop.Event) { + parent := r.parentCtx(r.turnCtx) + name := e.ToolName + if name == "" { + name = "unknown" + } + _, span := r.tracer.Start(parent, spanToolPrefix+name, trace.WithAttributes( + attrToolName.String(name), + attrToolCallID.String(e.ToolCallID), + )) + // Key by call id when present so parallel tool calls within a turn end + // against the right span; fall back to name for providers that omit ids. + r.tools[r.toolKey(e)] = span +} + +func (r *Recorder) endTool(e loop.Event) { + key := r.toolKey(e) + span, ok := r.tools[key] + if !ok { + return + } + delete(r.tools, key) + isErr := e.ToolResult != nil && e.ToolResult.IsError + span.SetAttributes(attrToolError.Bool(isErr)) + if isErr { + span.SetStatus(codes.Error, "tool reported an error") + } + span.End() +} + +func (r *Recorder) endTurn() { + // A normal turn has already ended its message; close any stragglers so + // an error path cannot leak the span. + if r.msg != nil { + r.msg.End() + r.msg = nil + } + if r.turn != nil { + r.turn.End() + r.turn = nil + r.turnCtx = nil + } +} + +func (r *Recorder) recordError(e loop.Event) { + // Attribute the failure to the deepest open span for precision, and + // also flag the root so the whole trace surfaces as failed in backends + // that filter on root status. + if span := r.innermost(); span != nil { + span.SetStatus(codes.Error, e.Error) + } + if r.root != nil { + r.root.SetStatus(codes.Error, e.Error) + } +} + +func (r *Recorder) endRun() { + r.closeAll() +} + +// closeAll ends every open span (tools, message, turn, root) so no span +// leaks if the loop exits without the matching end events (cancellation, +// provider error, max-turns). +func (r *Recorder) closeAll() { + for key, span := range r.tools { + span.End() + delete(r.tools, key) + } + if r.msg != nil { + r.msg.End() + r.msg = nil + } + if r.turn != nil { + r.turn.End() + r.turn = nil + r.turnCtx = nil + } + if r.root != nil { + r.root.End() + r.root = nil + r.rootCtx = nil + } +} + +// innermost returns the deepest currently-open span for error attribution, +// preferring an open message, then the turn, then the root. +func (r *Recorder) innermost() trace.Span { + switch { + case r.msg != nil: + return r.msg + case r.turn != nil: + return r.turn + default: + return r.root + } +} + +func (r *Recorder) parentCtx(ctx context.Context) context.Context { + if ctx != nil { + return ctx + } + if r.rootCtx != nil { + return r.rootCtx + } + return r.base +} + +func (r *Recorder) toolKey(e loop.Event) string { + if e.ToolCallID != "" { + return e.ToolCallID + } + return "name:" + e.ToolName +} diff --git a/tracing/recorder_test.go b/tracing/recorder_test.go new file mode 100644 index 0000000..8f767a9 --- /dev/null +++ b/tracing/recorder_test.go @@ -0,0 +1,239 @@ +package tracing + +import ( + "testing" + + "github.com/erain/glue/loop" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +// newTestRecorder returns a Recorder writing into an in-memory span +// recorder so tests can assert on the emitted span tree. +func newTestRecorder(t *testing.T, opts ...Option) (*Recorder, *tracetest.SpanRecorder) { + t.Helper() + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + return NewRecorder(tp.Tracer("test"), opts...), sr +} + +func feed(r *Recorder, events ...loop.Event) { + for _, e := range events { + r.Handle(e) + } +} + +func endedByName(spans []sdktrace.ReadOnlySpan) map[string]sdktrace.ReadOnlySpan { + out := make(map[string]sdktrace.ReadOnlySpan, len(spans)) + for _, s := range spans { + out[s.Name()] = s + } + return out +} + +func attrValue(s sdktrace.ReadOnlySpan, key attribute.Key) (attribute.Value, bool) { + for _, kv := range s.Attributes() { + if kv.Key == key { + return kv.Value, true + } + } + return attribute.Value{}, false +} + +func TestRecorderHappyPath(t *testing.T) { + rec, sr := newTestRecorder(t, WithSessionID("sess-1")) + + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventMessageStart, Message: &loop.Message{Model: "claude", Provider: "anthropic"}}, + loop.Event{Type: loop.EventMessageEnd, Message: &loop.Message{ + Model: "claude", + Provider: "anthropic", + StopReason: loop.StopReasonToolUse, + Usage: &loop.Usage{InputTokens: 10, OutputTokens: 7, TotalTokens: 17}, + }}, + loop.Event{Type: loop.EventToolStart, ToolName: "read_file", ToolCallID: "call-1"}, + loop.Event{Type: loop.EventToolEnd, ToolName: "read_file", ToolCallID: "call-1", ToolResult: &loop.ToolResult{}}, + loop.Event{Type: loop.EventTurnEnd}, + loop.Event{Type: loop.EventLoopEnd}, + ) + + spans := sr.Ended() + if len(spans) != 4 { + t.Fatalf("expected 4 spans, got %d: %v", len(spans), spanNames(spans)) + } + byName := endedByName(spans) + + run := mustSpan(t, byName, spanRun) + turn := mustSpan(t, byName, spanTurn) + gen := mustSpan(t, byName, spanGenerate) + tool := mustSpan(t, byName, spanToolPrefix+"read_file") + + // Span tree: run -> turn -> {generate, tool}. + if !turn.Parent().Equal(run.SpanContext()) { + t.Error("turn is not a child of run") + } + if !gen.Parent().Equal(turn.SpanContext()) { + t.Error("generate is not a child of turn") + } + if !tool.Parent().Equal(turn.SpanContext()) { + t.Error("tool is not a child of turn") + } + // All share one trace. + if run.SpanContext().TraceID() != tool.SpanContext().TraceID() { + t.Error("spans are not in the same trace") + } + + // Session id propagates to the root. + if v, ok := attrValue(run, attrSessionID); !ok || v.AsString() != "sess-1" { + t.Errorf("missing/incorrect session id attr: %v ok=%v", v.AsString(), ok) + } + // Usage + finish reason land on the generate span. + if v, ok := attrValue(gen, attrInputTokens); !ok || v.AsInt64() != 10 { + t.Errorf("input tokens = %v ok=%v, want 10", v.AsInt64(), ok) + } + if v, ok := attrValue(gen, attrFinishReason); !ok || v.AsString() != string(loop.StopReasonToolUse) { + t.Errorf("finish reason = %v ok=%v", v.AsString(), ok) + } + // Tool error flag is false for a clean result. + if v, ok := attrValue(tool, attrToolError); !ok || v.AsBool() { + t.Errorf("tool is_error = %v ok=%v, want false", v.AsBool(), ok) + } +} + +func TestRecorderToolError(t *testing.T) { + rec, sr := newTestRecorder(t) + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventToolStart, ToolName: "shell_exec", ToolCallID: "c1"}, + loop.Event{Type: loop.EventToolEnd, ToolName: "shell_exec", ToolCallID: "c1", ToolResult: &loop.ToolResult{IsError: true}}, + loop.Event{Type: loop.EventTurnEnd}, + loop.Event{Type: loop.EventLoopEnd}, + ) + tool := mustSpan(t, endedByName(sr.Ended()), spanToolPrefix+"shell_exec") + if v, ok := attrValue(tool, attrToolError); !ok || !v.AsBool() { + t.Errorf("tool is_error = %v ok=%v, want true", v.AsBool(), ok) + } + if tool.Status().Code != codes.Error { + t.Errorf("tool status = %v, want Error", tool.Status().Code) + } +} + +func TestRecorderParallelTools(t *testing.T) { + rec, sr := newTestRecorder(t) + // Parallel mode emits both tool_start events before either tool_end. + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventToolStart, ToolName: "a", ToolCallID: "c1"}, + loop.Event{Type: loop.EventToolStart, ToolName: "b", ToolCallID: "c2"}, + loop.Event{Type: loop.EventToolEnd, ToolName: "b", ToolCallID: "c2", ToolResult: &loop.ToolResult{}}, + loop.Event{Type: loop.EventToolEnd, ToolName: "a", ToolCallID: "c1", ToolResult: &loop.ToolResult{}}, + loop.Event{Type: loop.EventTurnEnd}, + loop.Event{Type: loop.EventLoopEnd}, + ) + byName := endedByName(sr.Ended()) + a := mustSpan(t, byName, spanToolPrefix+"a") + b := mustSpan(t, byName, spanToolPrefix+"b") + if v, _ := attrValue(a, attrToolCallID); v.AsString() != "c1" { + t.Errorf("span a call id = %q, want c1", v.AsString()) + } + if v, _ := attrValue(b, attrToolCallID); v.AsString() != "c2" { + t.Errorf("span b call id = %q, want c2", v.AsString()) + } +} + +func TestRecorderErrorClosesOpenSpans(t *testing.T) { + rec, sr := newTestRecorder(t) + // A provider error mid-turn: no turn_end is emitted, loop_end follows. + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventMessageStart, Message: &loop.Message{Model: "x"}}, + loop.Event{Type: loop.EventError, Error: "stream closed"}, + loop.Event{Type: loop.EventLoopEnd}, + ) + spans := sr.Ended() + if len(spans) != 3 { + t.Fatalf("expected run+turn+generate (3) spans, got %d: %v", len(spans), spanNames(spans)) + } + byName := endedByName(spans) + run := mustSpan(t, byName, spanRun) + if run.Status().Code != codes.Error { + t.Errorf("root status = %v, want Error", run.Status().Code) + } + gen := mustSpan(t, byName, spanGenerate) + if gen.Status().Code != codes.Error { + t.Errorf("generate status = %v, want Error (innermost open span)", gen.Status().Code) + } +} + +func TestRecorderNewRunClosesLeakedSpans(t *testing.T) { + rec, sr := newTestRecorder(t) + // First run never receives loop_end (e.g. process killed mid-prompt); + // the next loop_start must not leak the prior trace's open spans. + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventTurnEnd}, + loop.Event{Type: loop.EventLoopEnd}, + ) + // 2 runs + 2 turns = 4 spans, all ended, none leaked. + if got := len(sr.Ended()); got != 4 { + t.Fatalf("expected 4 ended spans, got %d: %v", got, spanNames(sr.Ended())) + } +} + +func TestRecorderNilTracerNoop(t *testing.T) { + var rec *Recorder + rec.Handle(loop.Event{Type: loop.EventLoopStart}) // nil receiver: must not panic + + rec = NewRecorder(nil) + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventTurnStart}, + loop.Event{Type: loop.EventLoopEnd}, + ) // nil tracer: must not panic +} + +func TestRecorderBaseContextParenting(t *testing.T) { + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + tracer := tp.Tracer("test") + + outerCtx, outer := tracer.Start(t.Context(), "outer") + rec := NewRecorder(tracer, WithBaseContext(outerCtx)) + feed(rec, + loop.Event{Type: loop.EventLoopStart}, + loop.Event{Type: loop.EventLoopEnd}, + ) + outer.End() + + run := mustSpan(t, endedByName(sr.Ended()), spanRun) + if !run.Parent().Equal(outer.SpanContext()) { + t.Error("run span should nest under the provided base context") + } +} + +func mustSpan(t *testing.T, byName map[string]sdktrace.ReadOnlySpan, name string) sdktrace.ReadOnlySpan { + t.Helper() + s, ok := byName[name] + if !ok { + t.Fatalf("missing span %q", name) + } + return s +} + +func spanNames(spans []sdktrace.ReadOnlySpan) []string { + names := make([]string, len(spans)) + for i, s := range spans { + names[i] = s.Name() + } + return names +}