agenttrace

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 4, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

Documentation

Overview

Package agenttrace provides tracing infrastructure for AI agent interactions.

Overview

This package contains the foundational types for tracking agent executions:

  • ExecutionContext: Reconciler-level metadata (PR, path, commit, turn number) for trace enrichment
  • Trace[T]: Complete agent interaction from prompt to result
  • ToolCall[T]: Individual tool invocation within a trace
  • Tracer[T]: Interface for creating and managing traces

Separation of Concerns

The agenttrace package provides low-level tracing primitives. Higher-level evaluation helpers (NoErrors, ExactToolCalls, etc.), observers, and metrics reporters are in the evals package which builds on top of this package.

Usage

Set execution context for trace enrichment:

ctx = agenttrace.WithExecutionContext(ctx, agenttrace.ExecutionContext{
	ReconcilerKey:  "pr:chainguard-dev/enterprise-packages/41025",
	ReconcilerType: "pr",
	CommitSHA:      "abc123",
	TurnNumber:     1,
})

Create and use traces:

tracer := agenttrace.ByCode[string](func(trace *agenttrace.Trace[string]) {
	log.Printf("Trace completed: %s", trace.ID)
})
ctx = agenttrace.WithTracer[string](ctx, tracer)

trace, done := agenttrace.StartTrace[string](ctx, "Analyze the security report")
toolCall := trace.StartToolCall("tc1", "file-reader", map[string]any{
	"path": "/var/logs/security.log",
})
toolCall.Complete("File content here", nil)
done("Analysis complete", nil)

Index

Examples

Constants

View Source
const (
	SystemAnthropic    = "anthropic"
	SystemGoogleVertex = "google.vertex"
	SystemOpenAI       = "openai"
)

Canonical gen_ai.system values per the OpenTelemetry GenAI semantic conventions. Executors pass these to BeginTurn so downstream eval tools (Braintrust, Langfuse, …) can filter traces by provider without consumers needing to remember the exact spelling.

View Source
const (
	// EventType is the CloudEvent type for agent trace records.
	EventType = "dev.chainguard.driftlessaf.agent.trace.v1"
)

Variables

This section is empty.

Functions

func GetDefaultAgentName added in v0.6.0

func GetDefaultAgentName(ctx context.Context) string

GetDefaultAgentName returns the agent name stored in the context by WithDefaultAgentName, or "" if none was set.

func GetDefaultNameFn added in v0.6.0

func GetDefaultNameFn(ctx context.Context) func(ExecutionContext) string

GetDefaultNameFn returns the name function stored in the context by WithDefaultNameFn, or nil if none was set.

func NewBrokerClient added in v0.4.1

func NewBrokerClient(ctx context.Context, brokerURL string) cloudevents.Client

NewBrokerClient creates a CloudEvents HTTP client authenticated with an ID token for the given broker URL. Call this once at startup and pass the client to WithCloudEventEmission or middleware that wraps it.

If brokerURL is empty or client construction fails, NewBrokerClient returns nil with a warning log. Callers should treat a nil client as "emission disabled" and skip wrapping the tracer.

func WithDefaultAgentName added in v0.6.0

func WithDefaultAgentName(ctx context.Context, name string) context.Context

WithDefaultAgentName returns a context carrying the given agent name so any subsequent StartTrace call without an explicit WithAgentName option emits gen_ai.agent.name=<name> on the root invoke_agent span. Callers building a reconciler that drives multiple executors can set this once at the top of the chain to attach a stable agent name (e.g. "loganalyzer", "judge", "fixer") to every trace.

func WithDefaultNameFn added in v0.6.0

func WithDefaultNameFn(ctx context.Context, fn func(ExecutionContext) string) context.Context

WithDefaultNameFn returns a context carrying a name function invoked by newTrace to build the braintrust.span_attributes.name attribute from the ExecutionContext. Callers at the reconciler layer (where the GitHub Resource is available) use this to stamp PR-aware labels such as "autofix: pr:chainguard-dev/mono#38632" on every subsequent trace.

func WithExecutionContext

func WithExecutionContext(ctx context.Context, execCtx ExecutionContext) context.Context

WithExecutionContext adds execution context to the Go context

Example

ExampleWithExecutionContext demonstrates attaching execution context to a context for trace enrichment.

package main

import (
	"context"
	"fmt"

	"chainguard.dev/driftlessaf/agents/agenttrace"
)

func main() {
	ctx := context.Background()
	ctx = agenttrace.WithExecutionContext(ctx, agenttrace.ExecutionContext{
		ReconcilerKey:  "pr:chainguard-dev/enterprise-packages/42",
		ReconcilerType: "pr",
		CommitSHA:      "abc123",
		TurnNumber:     1,
	})

	ec := agenttrace.GetExecutionContext(ctx)
	fmt.Printf("key=%s turn=%d\n", ec.ReconcilerKey, ec.TurnNumber)
}
Output:
key=pr:chainguard-dev/enterprise-packages/42 turn=1

func WithPayloadsEnabled added in v0.6.0

func WithPayloadsEnabled(ctx context.Context, enabled bool) context.Context

WithPayloadsEnabled returns a context that opts in (or out) of emitting raw prompt / completion payloads as OTel attributes on the root invoke_agent span (gen_ai.prompt, gen_ai.input.messages, gen_ai.completion, gen_ai.output.messages). The default — when nothing is set on ctx — is false so library consumers don't accidentally leak PII-bearing prompts to a third-party eval backend.

Consumer main packages read their own env var (e.g. DRIFTLESSAF_LLM_PAYLOADS) at startup and set this flag on the base context before handing off to the reconciler or executor. Keeping the decision on ctx (rather than a process-wide env read at package init) matches the repository's go-standards rule that library packages accept configuration as parameters instead of reading the environment directly.

func WithTracer

func WithTracer[T any](ctx context.Context, tracer Tracer[T]) context.Context

WithTracer returns a new context with the given tracer

Types

type ExecutionContext

type ExecutionContext struct {
	ReconcilerKey  string `json:"reconciler_key,omitempty"`  // Primary identifier: "pr:chainguard-dev/enterprise-packages/41025" or "path:chainguard-dev/mono/main/images/nginx"
	ReconcilerType string `json:"reconciler_type,omitempty"` // Type of reconciler: "pr" or "path"
	CommitSHA      string `json:"commit_sha,omitempty"`      // Git commit SHA (optional, for git-based reconcilers)
	TurnNumber     int    `json:"turn_number,omitempty"`     // Turn number for multi-turn agents (optional, 1, 2, 3, ...)
}

ExecutionContext provides reconciler-level context for agent executions. This context is used to enrich metrics with labels for tracking token usage and tool calls per reconciler (PR, path, etc.).

func GetExecutionContext

func GetExecutionContext(ctx context.Context) ExecutionContext

GetExecutionContext retrieves execution context from the Go context

func (ExecutionContext) EnrichAttributes

func (e ExecutionContext) EnrichAttributes(baseAttrs []attribute.KeyValue) []attribute.KeyValue

EnrichAttributes adds execution context attributes to the provided base attributes. This is used to enrich metrics with reconciler context using only BOUNDED labels.

Note: reconciler_key and commit_sha are NOT included in metrics to prevent unbounded cardinality (every PR and commit creates a new time series). These fields remain in the ExecutionContext for traces where cardinality is not a concern. Use trace exemplars to link from aggregated metrics to detailed per-PR traces.

func (ExecutionContext) Repository

func (e ExecutionContext) Repository() string

Repository extracts the repository from the reconciler key. For "pr:chainguard-dev/enterprise-packages/41025" returns "chainguard-dev/enterprise-packages" For "path:chainguard-dev/mono/main/images/nginx" returns "chainguard-dev/mono" Returns empty string if the format is invalid.

type LLMTurn added in v0.4.1

type LLMTurn[T any] struct {
	// contains filtered or unexported fields
}

LLMTurn represents a single LLM call within a trace.

LLMTurn is not safe for concurrent use: callers own a turn for the duration of a single model roundtrip and must not hand it across goroutines. The lifecycle methods (RecordTokens, End) read and mutate the accumulated record without locking because the contract is single-goroutine-per-turn, mirroring ToolCall.

func (*LLMTurn[T]) End added in v0.4.1

func (lt *LLMTurn[T]) End()

End ends the turn span and restores the trace context to before the turn. It is idempotent: subsequent calls are no-ops. On the first call, it appends the accumulated RecordedTurn to the parent trace's Turns slice.

func (*LLMTurn[T]) Fail added in v0.6.0

func (lt *LLMTurn[T]) Fail(err error)

Fail marks the turn as having ultimately failed and sets the OTEL span status to Error — mirroring ToolCall.Complete and Trace.complete on the failure path. RecordedTurn.Failed flips to true unconditionally; if err is non-nil it is also appended to Errors and recorded as a span exception event.

Fail(nil) is intentionally NOT symmetric with RecordError(nil). Calling Fail means "this turn ended in failure" — that signal must propagate even when the caller has no concrete error value (e.g. context cancellation surfaced upstream as a sentinel). The alternative (silent no-op) trades a loud false positive for a silent loss of failure signal in BQ; we prefer the loud one because it's discoverable. Callers that don't want to fail the turn must guard the call themselves: `if err != nil { lt.Fail(err) }`, which is exactly what the executor wiring does.

Call before End. Safe to call multiple times: the Failed flag is sticky (subsequent calls don't toggle it off), and each call with non-nil err appends to Errors.

func (*LLMTurn[T]) RecordError added in v0.6.0

func (lt *LLMTurn[T]) RecordError(err error)

RecordError appends err to the turn's chronological error list and emits an exception event on the OTEL span. It does NOT mark the turn as failed — use Fail for that. RecordError is the right call for transient errors the turn recovered from (e.g. a 503 that succeeded on retry); a non-empty Errors list with Failed=false is exactly that recovery shape.

A nil err is a no-op. Call before End.

func (*LLMTurn[T]) RecordTokens added in v0.4.1

func (lt *LLMTurn[T]) RecordTokens(inputTokens, outputTokens int64)

RecordTokens sets input/output token counts as span attributes on the turn span.

type ReasoningContent

type ReasoningContent struct {
	Thinking string `json:"thinking"`
}

ReasoningContent represents internal reasoning from an LLM

type RecordedTurn added in v0.6.0

type RecordedTurn struct {
	Index        int       `json:"index"`
	Model        string    `json:"model,omitempty"`
	System       string    `json:"system,omitempty"`
	InputTokens  int64     `json:"input_tokens,omitempty"`
	OutputTokens int64     `json:"output_tokens,omitempty"`
	StartTime    time.Time `json:"start_time"`
	EndTime      time.Time `json:"end_time"`
	// Errors is the chronological list of errors the turn encountered, including
	// transients that the turn recovered from. A non-empty list does NOT mean
	// the turn failed — see Failed for the terminal outcome. Populated via
	// LLMTurn.RecordError and LLMTurn.Fail.
	Errors []string `json:"errors,omitempty"`
	// Failed is the terminal outcome flag: true iff the turn ultimately failed.
	// A turn can have non-empty Errors and Failed=false (it recovered from
	// transient errors). Set by LLMTurn.Fail; defaults to false. Serialized
	// without omitempty so successful turns get an explicit `false` in BQ
	// instead of NULL — analytics queries can use `failed = FALSE` directly
	// without the three-valued-logic gotcha of NULL.
	Failed bool `json:"failed"`
}

RecordedTurn is the per-turn data captured on a Trace so each LLM turn surfaces as a queryable row in downstream sinks (e.g. BigQuery via the CloudEvent payload). Token counts reflect a single turn rather than cumulative totals on the parent Trace.

type StartTraceOption added in v0.6.0

type StartTraceOption func(*traceOptions)

StartTraceOption configures trace creation. Options let callers attach an agent name (static, for gen_ai.agent.name) and a dynamic name function (for braintrust.span_attributes.name — e.g. "autofix: pr:chainguard-dev/mono#38632").

func WithAgentName added in v0.6.0

func WithAgentName(name string) StartTraceOption

WithAgentName sets the static gen_ai.agent.name attribute on the root invoke_agent span (e.g. "loganalyzer", "judge", "fixer"). Also used as the fallback for braintrust.span_attributes.name when no nameFn is set.

func WithNameFn added in v0.6.0

func WithNameFn(fn func(ExecutionContext) string) StartTraceOption

WithNameFn sets a callback that produces the braintrust.span_attributes.name attribute (the label shown in the Braintrust UI) from the ExecutionContext. When nil or unset, braintrust.span_attributes.name falls back to agentName.

type ToolCall

type ToolCall[T any] struct {
	ID        string         `json:"id"`
	Name      string         `json:"name"`
	Params    map[string]any `json:"params"`
	Result    any            `json:"result"`
	Error     error          `json:"error,omitempty"`
	StartTime time.Time      `json:"start_time"`
	EndTime   time.Time      `json:"end_time"`
	// contains filtered or unexported fields
}

ToolCall represents a single tool invocation within a trace

func (*ToolCall[T]) Complete

func (tc *ToolCall[T]) Complete(result any, err error)

Complete marks the tool call as complete and adds it to the parent trace

func (*ToolCall[T]) Duration

func (tc *ToolCall[T]) Duration() time.Duration

Duration returns the duration of the tool call

type Trace

type Trace[T any] struct {
	ID          string             `json:"id"`
	OTelTraceID string             `json:"otel_trace_id,omitempty"`
	InputPrompt string             `json:"input_prompt"`
	ExecContext ExecutionContext   `json:"exec_context,omitempty"` // PR/commit metadata
	ToolCalls   []*ToolCall[T]     `json:"tool_calls"`
	Turns       []RecordedTurn     `json:"turns,omitempty"`
	Reasoning   []ReasoningContent `json:"reasoning,omitempty"`
	Result      T                  `json:"result"`
	Error       error              `json:"-"` // handled by MarshalJSON
	StartTime   time.Time          `json:"start_time"`
	EndTime     time.Time          `json:"end_time"`
	Metadata    map[string]any     `json:"metadata,omitempty"`

	// AgentName identifies which logical agent produced this trace (e.g.
	// "materializer", "skillup-reviewer"). Stamped by a middleware that
	// knows the agent identity at tracer construction time.
	AgentName string `json:"agent_name,omitempty"`

	// Source mirrors the CloudEvent Ce-Source header (typically the
	// reconciler's OCTO_IDENTITY). Duplicated into the payload so BigQuery
	// can query by service — the recorder records only event.Data().
	Source string `json:"source,omitempty"`

	// Token usage fields, populated by RecordTokenUsage / RecordCacheTokenUsage.
	Model               string `json:"model,omitempty"`
	InputTokens         int64  `json:"input_tokens,omitempty"`
	OutputTokens        int64  `json:"output_tokens,omitempty"`
	CacheReadTokens     int64  `json:"cache_read_tokens,omitempty"`
	CacheCreationTokens int64  `json:"cache_creation_tokens,omitempty"`
	// contains filtered or unexported fields
}

Trace represents a complete agent interaction from prompt to result.

Trace implements json.Marshaler so it can be serialized directly. The custom MarshalJSON converts the Error field to a string and excludes unexported runtime handles (mutex, context, span). Serialization is intended to happen after Complete — at that point the trace is immutable and no lock is needed.

func StartTrace

func StartTrace[T any](ctx context.Context, prompt string, opts ...StartTraceOption) (*Trace[T], func(T, error))

StartTrace starts a new trace using the tracer from the context and returns the trace along with a done callback. The caller must invoke done(result, err) when the operation completes; this fills in the trace and records it via the tracer. Capturing the tracer at start time means decorator composition works without a second context lookup.

opts customize the root invoke_agent span attributes — e.g. WithAgentName stamps gen_ai.agent.name, and WithNameFn produces a dynamic braintrust.span_attributes.name label based on ExecutionContext.

Example

ExampleStartTrace demonstrates creating and completing a trace.

package main

import (
	"context"
	"fmt"

	"chainguard.dev/driftlessaf/agents/agenttrace"
)

func main() {
	ctx := context.Background()

	tracer := agenttrace.ByCode[string](func(trace *agenttrace.Trace[string]) {
		fmt.Printf("Trace completed: %s\n", trace.Result)
	})
	ctx = agenttrace.WithTracer[string](ctx, tracer)

	_, done := agenttrace.StartTrace[string](ctx, "Analyze the report")
	done("analysis done", nil)
}
Output:
Trace completed: analysis done

func (*Trace[T]) BadToolCall

func (t *Trace[T]) BadToolCall(id, name string, params map[string]any, err error)

BadToolCall records a tool call that failed due to bad arguments or unknown tool

func (*Trace[T]) BeginTurn added in v0.4.1

func (t *Trace[T]) BeginTurn(turn int, system, modelName string) *LLMTurn[T]

BeginTurn starts a new LLM turn span as a child of the trace span. The trace context is updated so subsequent tool call spans are nested under this turn span. Call End() on the returned LLMTurn when the turn completes.

system is the OTel GenAI provider identifier: "openai", "anthropic", "google.vertex", etc. It powers provider filtering in eval tools.

Callers MUST call End() on the current turn before calling BeginTurn again. Overlapping turns corrupt the span hierarchy: the later End() restores a stale context, causing subsequent spans to be parented incorrectly.

func (*Trace[T]) Duration

func (t *Trace[T]) Duration() time.Duration

Duration returns the total duration of the trace

func (*Trace[T]) MarshalJSON added in v0.4.1

func (t *Trace[T]) MarshalJSON() ([]byte, error)

MarshalJSON implements json.Marshaler for Trace. It converts the Error field to a string and excludes unexported fields.

func (*Trace[T]) RecordCacheTokenUsage added in v0.2.0

func (t *Trace[T]) RecordCacheTokenUsage(cacheReadTokens, cacheCreationTokens int64)

RecordCacheTokenUsage records Anthropic prompt cache token metrics as span attributes using OpenTelemetry GenAI semantic conventions. These appear alongside gen_ai.usage.input_tokens and gen_ai.usage.output_tokens in Cloud Trace, enabling per-request visibility into how much of the input was served from cache vs fresh. See: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/

func (*Trace[T]) RecordTokenUsage

func (t *Trace[T]) RecordTokenUsage(model string, inputTokens, outputTokens int64)

RecordTokenUsage records model and token usage as span attributes for observability. This allows viewing token consumption directly in Cloud Trace without needing to cross-reference with metrics.

Token counts are emitted on the root invoke_agent span via OpenTelemetry GenAI semantic conventions (gen_ai.usage.input_tokens / gen_ai.usage.output_tokens) alongside legacy custom attributes. gen_ai.request.model is NOT emitted on the root span — per OTel GenAI semconv it's a turn-level concern, emitted on the "chat <model>" spans in BeginTurn. Langfuse reclassifies any root span carrying gen_ai.request.model as a "generation" observation, which is wrong for an orchestration span; Braintrust doesn't reclassify but semantically agrees. See: https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/

func (*Trace[T]) StartToolCall

func (t *Trace[T]) StartToolCall(id, name string, params map[string]any) *ToolCall[T]

StartToolCall starts a new tool call and returns it

func (*Trace[T]) String

func (t *Trace[T]) String() string

String returns a structured representation of the trace

type TraceCallback

type TraceCallback[T any] func(*Trace[T])

TraceCallback is a function that receives completed traces

type Tracer

type Tracer[T any] interface {
	// NewTrace creates a new trace with the given prompt. opts customize
	// root-span attributes (agent name, Braintrust span name callback, etc.).
	NewTrace(ctx context.Context, prompt string, opts ...StartTraceOption) *Trace[T]
	// RecordTrace records a completed trace
	RecordTrace(trace *Trace[T])
}

Tracer is the interface for creating and managing traces

func ByCode

func ByCode[T any](callbacks ...TraceCallback[T]) Tracer[T]

ByCode creates a new Tracer for code-based evals that invokes the given callbacks when traces are recorded

func NewDefaultTracer

func NewDefaultTracer[T any](_ context.Context) Tracer[T]

NewDefaultTracer creates a new default tracer that logs to clog. The trace is logged as a structured JSON document via MarshalJSON so that JSON log sinks (Cloud Logging, etc.) receive a parseable record.

We use trace.ctx (not the startup ctx) so that each log line carries the per-request context — including trace metadata, reconciler key, etc.

func TracerFromContext

func TracerFromContext[T any](ctx context.Context) Tracer[T]

TracerFromContext returns the tracer from the context, or creates a default tracer

func WithCloudEventEmission added in v0.4.1

func WithCloudEventEmission[T any](inner Tracer[T], client cloudevents.Client, source string) Tracer[T]

WithCloudEventEmission wraps inner so that each call to RecordTrace also emits the trace as a CloudEvent. The caller provides a pre-built cloudevents.Client (see NewBrokerClient) and a source identifier (e.g. the OCTO_IDENTITY of the reconciler). The CloudEvent type is always EventType.

Call Drain on the returned tracer (via type assertion) before process exit to flush in-flight events.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL