Skip to content

Observability

GoAI's observability is built on nine lifecycle hooks: OnRequest, OnResponse, OnToolCallStart, OnToolCall, OnStepFinish, OnFinish, OnBeforeToolExecute, OnAfterToolExecute, and OnBeforeStep. Any observability provider can plug into these hooks to trace LLM calls, tool executions, and multi-step agent runs.

How It Works

Observability integrations typically expose helper options. For Langfuse, the primary API is langfuse.WithTracing(...):

go
result, err := goai.GenerateText(ctx, model,
	langfuse.WithTracing(langfuse.TraceName("my-agent")),
	goai.WithPrompt("Hello"),
)
_ = result
_ = err

Each hook fires at a specific point in the request lifecycle:

HookWhenData
OnRequestBefore each LLM callModel, full message history, tool count
OnResponseAfter each LLM callLatency, token usage, finish reason, errors
OnToolCallStartBefore each tool executionTool call ID, tool name, step, input
OnToolCallAfter each tool executionTool name, input/output, duration, errors
OnStepFinishAfter each step completesStep number, finish reason, tool calls
OnFinishAfter all steps completeStepsExhausted, TotalSteps, TotalUsage, FinishReason
OnBeforeToolExecute*Before each tool's Execute functionTool call ID, tool name, step, input; can skip execution
OnAfterToolExecute*After each tool's Execute functionTool call ID, tool name, output, error; can modify output
OnBeforeStep*Before each LLM call (step 2+)Step number, messages; can inject messages or stop loop

* Interceptor hooks: only one callback supported per hook (setting a second replaces the first). The first six hooks support multiple callbacks.

This design means observability never touches the core SDK. Providers are optional imports with zero impact on non-instrumented code.

Available Providers

ProviderPackageStatus
Langfuseobservability/langfuseShipped
OpenTelemetryobservability/otelShipped

Custom Provider

Any observability backend can be integrated by implementing the hooks pattern:

go
func MyTracer() []goai.Option {
    return []goai.Option{
        goai.WithOnRequest(func(info goai.RequestInfo) {
            // start span
        }),
        goai.WithOnResponse(func(info goai.ResponseInfo) {
            // record latency, usage, errors
        }),
        goai.WithOnToolCallStart(func(info goai.ToolCallStartInfo) {
            // record tool execution start
        }),
        goai.WithOnToolCall(func(info goai.ToolCallInfo) {
            // record tool execution
        }),
        goai.WithOnStepFinish(func(step goai.StepResult) {
            // close span, flush
        }),
        goai.WithOnFinish(func(info goai.FinishInfo) {
            // record final status, detect max_steps exhaustion
        }),
    }
}

Langfuse

Langfuse is an open-source LLM observability platform. The observability/langfuse package provides zero-dependency tracing with automatic trace hierarchy, tool spans, and token usage tracking.

Setup

bash
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...
export LANGFUSE_HOST=https://cloud.langfuse.com  # or your self-hosted URL
go
import "github.com/zendev-sh/goai/observability/langfuse"

Credentials can be read from env vars or overridden via tracing options.

Trace Hierarchy

Each run creates a structured trace in Langfuse:

Trace
└── Span("agent")            - wraps the entire run
    ├── Generation("step-1") - LLM call with input/output, model, usage
    ├── Span("tool-name")    - tool execution with input/output, duration
    └── Generation("step-2") - final LLM call

Generations include model name, token usage (input/output/reasoning/cache), and finish reason. Tool spans include the tool name, input arguments, output, and duration.

Usage Patterns

One-off calls with WithTracing():

go
result, err := goai.GenerateText(ctx, model,
    langfuse.WithTracing(
        langfuse.TraceName("my-agent"),
        langfuse.UserID("user-42"),
        langfuse.Tags("prod", "demo"),
    ),
    goai.WithSystem("You are a helpful assistant."),
    goai.WithPrompt("What is Go?"),
)
_ = result
_ = err

Structured output with tools:

go
result, err := goai.GenerateObject[WeatherReport](ctx, model,
    langfuse.WithTracing(
        langfuse.TraceName("weather-agent"),
        langfuse.SessionID("session-abc123"),
        langfuse.Version("1.0.0"),
    ),
    goai.WithSystem("You are a weather assistant."),
    goai.WithPrompt("What's the weather in Tokyo?"),
    goai.WithTools(weatherTool),
    goai.WithMaxSteps(3),
)
_ = result
_ = err

Deprecated compatibility API (langfuse.New(...).Run()/With()) remains available but is superseded by WithTracing.

Config

WithTracing accepts option functions for the same fields previously set via Config:

go
langfuse.WithTracing(
    langfuse.TraceName("my-agent"),
    langfuse.UserID("user-42"),
    langfuse.SessionID("session-abc123"),
    langfuse.Tags("prod", "v1"),
    langfuse.Release("2026.04.03"),
    langfuse.Version("1.0.0"),
    langfuse.Environment("production"),
    langfuse.Metadata(map[string]any{"key": "value"}),
    langfuse.PromptName("my-prompt"),
    langfuse.PromptVersion(1),
    langfuse.PublicKey("pk-lf-..."),  // override env var
    langfuse.SecretKey("sk-lf-..."),  // override env var
    langfuse.Host("https://cloud.langfuse.com"), // override env var
    langfuse.OnFlushError(func(err error) { log.Printf("langfuse flush error: %v", err) }),
    langfuse.EagerToolSpans(true), // opt-in: real-time tool visibility (extra HTTP per tool)
)

Error Handling

Tracing is best-effort. A Langfuse outage never crashes your app:

  • OnFlushError receives flush errors if set; otherwise they are silently discarded
  • If the LLM call fails, a partial trace is flushed with level: ERROR

Concurrency

Each traced call has isolated state and is safe to run concurrently.

Example

See the full runnable example for a multi-step agent with tool calls and structured output.


OpenTelemetry

The observability/otel package provides OpenTelemetry tracing and metrics for GoAI calls. It emits spans following OpenTelemetry Semantic Conventions for Generative AI and works with any OTel-compatible backend (Jaeger, Datadog, Honeycomb, Grafana Tempo, etc.).

Setup

bash
go get github.com/zendev-sh/goai/observability/otel
# Plus your preferred exporter, e.g.:
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
go get go.opentelemetry.io/otel/sdk/trace
go
import goaiotel "github.com/zendev-sh/goai/observability/otel"

By default, WithTracing() uses the global TracerProvider and MeterProvider registered via otel.SetTracerProvider / otel.SetMeterProvider. Override with explicit options if needed.

Production setup with OTLP exporter:

go
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

exporter, err := otlptracehttp.New(ctx,
    otlptracehttp.WithEndpoint("tempo.example.com:4318"),
    otlptracehttp.WithHeaders(map[string]string{
        "Authorization": "Bearer <API_KEY>",
    }),
)
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter))
defer tp.Shutdown(ctx)
otel.SetTracerProvider(tp)

Or use standard OTel environment variables (the SDK reads them automatically):

bash
export OTEL_EXPORTER_OTLP_ENDPOINT=https://tempo.example.com:4318
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <API_KEY>"

Span Hierarchy

Each run creates a structured trace:

chat (root)
├── chat {model}      -- LLM API call (step 1) with model, usage, finish reason
├── execute_tool {tool} -- tool execution with duration
└── chat {model}      -- LLM API call (step 2)

Usage Patterns

Basic -- uses the global TracerProvider:

go
result, err := goai.GenerateText(ctx, model,
    goaiotel.WithTracing(),
    goai.WithPrompt("What is Go?"),
)
_ = result
_ = err

With custom providers and span name:

go
result, err := goai.GenerateText(ctx, model,
    goaiotel.WithTracing(
        goaiotel.WithTracerProvider(tp),
        goaiotel.WithMeterProvider(mp),
        goaiotel.WithSpanName("my-agent"),
    ),
    goai.WithPrompt("What is Go?"),
)
_ = result
_ = err

With custom attributes and message recording:

go
result, err := goai.GenerateObject[WeatherReport](ctx, model,
    goaiotel.WithTracing(
        goaiotel.WithSpanName("weather-agent"),
        goaiotel.WithAttributes(
            attribute.String("user.id", "user-42"),
            attribute.String("session.id", "session-abc123"),
        ),
        goaiotel.RecordInputMessages(true),
        goaiotel.RecordOutputMessages(true),
    ),
    goai.WithSystem("You are a weather assistant."),
    goai.WithPrompt("What's the weather in Tokyo?"),
    goai.WithTools(weatherTool),
    goai.WithMaxSteps(3),
)
_ = result
_ = err

Config

OptionDescription
WithTracerProvider(tp)Use a specific trace.TracerProvider instead of the global one
WithMeterProvider(mp)Use a specific metric.MeterProvider instead of the global one
WithSpanName(name)Set the root span name (default: "chat")
WithAttributes(attrs...)Attach custom attribute.KeyValue pairs to the root span
RecordInputMessages(bool)Record full input messages as span events (default: false)
RecordOutputMessages(bool)Record full output messages as span events (default: false)
RecordToolIO(bool)Record tool input/output as span events (default: false)

Semantic Conventions

Spans are annotated with gen_ai.* attributes following the OpenTelemetry GenAI semantic conventions:

AttributeDescription
gen_ai.systemAlways "goai"
gen_ai.operation.nameOperation name (e.g. "chat")
gen_ai.request.modelModel ID
gen_ai.response.finish_reasonsFinish reason(s)
gen_ai.usage.input_tokensInput token count
gen_ai.usage.output_tokensOutput token count
gen_ai.usage.total_tokensTotal token count (only if > 0)
goai.usage.reasoning_tokensReasoning/thinking token count (only if > 0)
gen_ai.usage.cache_read.input_tokensPrompt cache read tokens (only if > 0)
gen_ai.usage.cache_creation.input_tokensPrompt cache write tokens (only if > 0)
goai.step1-based step index (on LLM call and tool spans)
gen_ai.tool.nameTool name (on tool spans)
gen_ai.tool.call.idTool call ID (on tool spans)
goai.request.message_countMessage count per LLM call
goai.request.tool_countTool count per LLM call
goai.tool.skippedTrue when tool was skipped by OnBeforeToolExecute
goai.tool.input_overriddenTrue when tool input was overridden
goai.tool.context_overriddenTrue when tool context was overridden
goai.tool.output_modifiedTrue when tool output was modified by OnAfterToolExecute
goai.tool.metadata.*Consumer metadata from OnAfterToolExecute
goai.stopped_at_stepStep number where OnBeforeStep stopped the loop
goai.step.injected_messagesNumber of messages injected by OnBeforeStep
goai.tool.deadlineDeadline from overridden tool context (ISO 8601)
goai.provider_metadata.*Provider-specific metadata from StepResult
goai.termination_reason"natural", "max_steps", or "hook_stopped"
goai.stopped_by_hookTrue when OnBeforeStep stopped the loop
gen_ai.response.modelActual response model (may differ from request)
gen_ai.response.idProvider response ID
http.response.status_codeHTTP status code (only if > 0)

Metrics

When a MeterProvider is configured, the following metrics are recorded:

MetricTypeDescription
gen_ai.client.token.usageInt64 HistogramToken counts per type (gen_ai.token.type = "input" / "output")
gen_ai.client.operation.durationFloat64 HistogramLLM call duration in seconds
goai.tool.durationFloat64 HistogramTool execution duration in seconds (includes gen_ai.tool.name attribute)
goai.loop.early_stopInt64 CounterLoop early stops by OnBeforeStep hook
goai.conversation.message_countInt64 GaugeMessage count per step

Error Handling

Tracing is best-effort. An OTel collector outage never crashes your app:

  • Failed spans are recorded with codes.Error status and the error message
  • If the LLM call fails, the span is ended with error status and the error is recorded

Concurrency

Each traced call has isolated state and is safe to run concurrently. The underlying TracerProvider and MeterProvider handle their own thread safety.

Example

See the full runnable example for a multi-step agent with tool calls and structured output.

Released under the MIT License.