From ddd82a9f177c5c5aa0ddfc2e9f5e306ee4ca6f28 Mon Sep 17 00:00:00 2001 From: Prajwal Raymond Moras Date: Wed, 17 Jun 2026 19:49:13 +0530 Subject: [PATCH 1/2] callbacks/otel: add OpenTelemetry callback handler --- callbacks/otel/README.md | 134 ++++++++++++ callbacks/otel/handler.go | 274 +++++++++++++++++++++++++ callbacks/otel/handler_test.go | 252 +++++++++++++++++++++++ callbacks/otel/options.go | 38 ++++ examples/otel-callback-example/go.mod | 25 +++ examples/otel-callback-example/go.sum | 47 +++++ examples/otel-callback-example/main.go | 82 ++++++++ 7 files changed, 852 insertions(+) create mode 100644 callbacks/otel/README.md create mode 100644 callbacks/otel/handler.go create mode 100644 callbacks/otel/handler_test.go create mode 100644 callbacks/otel/options.go create mode 100644 examples/otel-callback-example/go.mod create mode 100644 examples/otel-callback-example/go.sum create mode 100644 examples/otel-callback-example/main.go diff --git a/callbacks/otel/README.md b/callbacks/otel/README.md new file mode 100644 index 000000000..b6a419e9c --- /dev/null +++ b/callbacks/otel/README.md @@ -0,0 +1,134 @@ +# callbacks/otel + +OpenTelemetry callback handler for LangChainGo. + +This package implements the `callbacks.Handler` interface and creates +OpenTelemetry trace spans for LLM, chain, tool, and retriever lifecycle +events. It uses GenAI semantic convention attribute keys and is designed +to be safe by default — no prompt or completion text is captured unless +you explicitly opt in. + +## Installation + +This package is part of `github.com/tmc/langchaingo`. Import it directly: + +```go +import otelcallback "github.com/tmc/langchaingo/callbacks/otel" +``` + +Because it introduces `go.opentelemetry.io/otel` as a dependency, it lives +in its own subpackage so users who do not need tracing pay no dependency cost. + +## Quick Start + +```go +package main + +import ( + "context" + + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + + otelcallback "github.com/tmc/langchaingo/callbacks/otel" + "github.com/tmc/langchaingo/llms/openai" +) + +func main() { + // 1. Set up an exporter (stdout here; swap for OTLP in production). + exporter, _ := stdouttrace.New(stdouttrace.WithPrettyPrint()) + tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) + defer tp.Shutdown(context.Background()) + + // 2. Create the handler. + handler := otelcallback.NewHandler( + otelcallback.WithTracerProvider(tp), + ) + + // 3. Pass it to your LLM or chain via the callbacks option. + llm, _ := openai.New(openai.WithCallbacksHandler(handler)) + _ = llm +} +``` + +## Options + +| Option | Default | Description | +|---|---|---| +| `WithTracerProvider(tp)` | Global OTel provider | Use a specific `trace.TracerProvider`. If omitted, the global OTel provider is used (no-op until the app configures a real one). | +| `WithCaptureInput(bool)` | `false` | Capture prompt / input text as span attributes. **See privacy warning below.** | +| `WithCaptureOutput(bool)` | `false` | Capture completion / output text as span attributes. **See privacy warning below.** | + +## Instrumented Events + +| Lifecycle event | Span name | Span kind | Key attributes | +|---|---|---|---| +| `HandleLLMStart` | `llm.call` | `CLIENT` | `gen_ai.operation.name`, `gen_ai.prompt.count` | +| `HandleLLMGenerateContentStart` / `End` | `llm.generate_content` | `CLIENT` | `gen_ai.operation.name`, `gen_ai.prompt.message_count`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` | +| `HandleChainStart` / `End` | `chain` | `INTERNAL` | `gen_ai.operation.name` | +| `HandleToolStart` / `End` | `tool.call` | `INTERNAL` | `gen_ai.operation.name` | +| `HandleRetrieverStart` / `End` | `retriever.query` | `INTERNAL` | `gen_ai.operation.name`, `retriever.document_count` | +| `HandleLLMError` / `HandleChainError` / `HandleToolError` | *(ends active span)* | — | `error.type`, OTel exception event, span status `ERROR` | +| `HandleStreamingFunc` | *(no span)* | — | Intentionally omitted — per-chunk spans would be extremely noisy | + +Token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`) is +read from `ContentResponse.Choices[0].GenerationInfo["InputTokens"]` and +`["OutputTokens"]`. Not all LLM providers populate these fields. + +## Privacy Warning + +**Prompt and completion content is NOT recorded by default.** + +If you enable content capture, the raw text of every prompt and every +model completion will appear as span attributes in your trace backend. +Only enable this if: + +- You understand what data is being sent to users' prompts in your application. +- Your trace backend (Jaeger, Tempo, Honeycomb, etc.) is appropriately secured. +- You have reviewed your organisation's data retention and privacy policies. + +To opt in explicitly: + +```go +handler := otelcallback.NewHandler( + otelcallback.WithTracerProvider(tp), + otelcallback.WithCaptureInput(true), // records prompt text + otelcallback.WithCaptureOutput(true), // records completion text +) +``` + +## Running the Example + +A working example using the stdout exporter is in: + +``` +examples/otel-callback-example/ +``` + +Run it with: + +```bash +cd examples/otel-callback-example +go run . +``` + +It prints JSON-formatted OTel spans to stdout so you can see exactly what +the handler emits without needing any external collector. + +## Semantic Conventions Note + +The `gen_ai.*` attribute keys used in this package follow the +[OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/). +They are defined as local constants in `handler.go` because +`go.opentelemetry.io/otel/semconv` does not yet export stable GenAI +constants. When that changes, the constants will be updated to use the +official package. + +## Running Tests + +```bash +go test ./callbacks/otel/... +``` + +Tests use `go.opentelemetry.io/otel/sdk/trace/tracetest` with an in-memory +span recorder — no external services or mocks required. diff --git a/callbacks/otel/handler.go b/callbacks/otel/handler.go new file mode 100644 index 000000000..179540735 --- /dev/null +++ b/callbacks/otel/handler.go @@ -0,0 +1,274 @@ +// Package otelcallback provides an OpenTelemetry callback handler for +// LangChainGo. It instruments LLM, chain, tool, and retriever lifecycle +// events as OpenTelemetry traces. +// +// Privacy: prompt and completion contents are NOT captured by default. +// Use WithCaptureInput and WithCaptureOutput to opt in explicitly. +package otelcallback + +import ( + "context" + "fmt" + "strconv" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "github.com/tmc/langchaingo/callbacks" + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/schema" +) + +const ( + instrumentationName = "github.com/tmc/langchaingo/callbacks/otel" + instrumentationVersion = "0.0.1" + + // GenAI semantic convention attribute keys. + // Defined locally because go.opentelemetry.io/otel/semconv does not yet + // expose stable GenAI constants. Update this file when they are stable. + attrGenAIOperationName = "gen_ai.operation.name" + attrGenAIUsageInputTok = "gen_ai.usage.input_tokens" + attrGenAIUsageOutputTok = "gen_ai.usage.output_tokens" + attrErrorType = "error.type" +) + +// Compile-time check: Handler must satisfy callbacks.Handler. +var _ callbacks.Handler = (*Handler)(nil) + +// Handler is an OpenTelemetry callback handler that creates trace spans for +// LangChainGo lifecycle events. +type Handler struct { + tracer trace.Tracer + captureInput bool + captureOutput bool + + // spans stores active trace.Span values keyed by context.Context. + // This bridges the stateless callback interface (which cannot return + // an enriched context) with the start/end span lifecycle. + spans sync.Map +} + +// NewHandler creates a new Handler. If no WithTracerProvider option is given, +// the global OpenTelemetry TracerProvider is used (which is a no-op tracer +// until the application configures a real exporter). +func NewHandler(opts ...Option) *Handler { + cfg := config{ + tracerProvider: otel.GetTracerProvider(), + } + for _, o := range opts { + o(&cfg) + } + return &Handler{ + tracer: cfg.tracerProvider.Tracer( + instrumentationName, + trace.WithInstrumentationVersion(instrumentationVersion), + ), + captureInput: cfg.captureInput, + captureOutput: cfg.captureOutput, + } +} + +// HandleLLMStart starts a span for the legacy Call-based LLM path. +func (h *Handler) HandleLLMStart(ctx context.Context, prompts []string) { + attrs := []attribute.KeyValue{ + attribute.String(attrGenAIOperationName, "llm.call"), + attribute.Int("gen_ai.prompt.count", len(prompts)), + } + _, span := h.tracer.Start(ctx, "llm.call", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(attrs...), + ) + h.spans.Store(ctx, span) +} + +// HandleLLMGenerateContentStart starts a span for the GenerateContent path. +func (h *Handler) HandleLLMGenerateContentStart(ctx context.Context, ms []llms.MessageContent) { + attrs := []attribute.KeyValue{ + attribute.String(attrGenAIOperationName, "llm.generate_content"), + attribute.Int("gen_ai.prompt.message_count", len(ms)), + } + _, span := h.tracer.Start(ctx, "llm.generate_content", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(attrs...), + ) + h.spans.Store(ctx, span) +} + +// HandleLLMGenerateContentEnd ends the GenerateContent span and records token usage. +func (h *Handler) HandleLLMGenerateContentEnd(ctx context.Context, res *llms.ContentResponse) { + span := h.loadAndDelete(ctx) + if span == nil { + return + } + defer span.End() + + if res == nil { + return + } + for _, choice := range res.Choices { + if choice == nil { + continue + } + if v, ok := toInt(choice.GenerationInfo["InputTokens"]); ok { + span.SetAttributes(attribute.Int(attrGenAIUsageInputTok, v)) + } + if v, ok := toInt(choice.GenerationInfo["OutputTokens"]); ok { + span.SetAttributes(attribute.Int(attrGenAIUsageOutputTok, v)) + } + if h.captureOutput && choice.Content != "" { + span.SetAttributes(attribute.String("gen_ai.completion", choice.Content)) + } + break + } +} + +// HandleLLMError records the error on the active LLM span and ends it. +func (h *Handler) HandleLLMError(ctx context.Context, err error) { + h.endWithError(ctx, err) +} + +// HandleChainStart starts a span for a chain execution. +func (h *Handler) HandleChainStart(ctx context.Context, _ map[string]any) { + _, span := h.tracer.Start(ctx, "chain", + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithAttributes(attribute.String(attrGenAIOperationName, "chain")), + ) + h.spans.Store(ctx, span) +} + +// HandleChainEnd ends the chain span. +func (h *Handler) HandleChainEnd(ctx context.Context, _ map[string]any) { + if span := h.loadAndDelete(ctx); span != nil { + span.End() + } +} + +// HandleChainError records the error on the chain span and ends it. +func (h *Handler) HandleChainError(ctx context.Context, err error) { + h.endWithError(ctx, err) +} + +// HandleToolStart starts a span for a tool execution. +func (h *Handler) HandleToolStart(ctx context.Context, input string) { + attrs := []attribute.KeyValue{ + attribute.String(attrGenAIOperationName, "tool"), + } + if h.captureInput { + attrs = append(attrs, attribute.String("tool.input", input)) + } + _, span := h.tracer.Start(ctx, "tool.call", + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithAttributes(attrs...), + ) + h.spans.Store(ctx, span) +} + +// HandleToolEnd ends the tool span. +func (h *Handler) HandleToolEnd(ctx context.Context, output string) { + span := h.loadAndDelete(ctx) + if span == nil { + return + } + defer span.End() + if h.captureOutput && output != "" { + span.SetAttributes(attribute.String("tool.output", output)) + } +} + +// HandleToolError records the error on the tool span and ends it. +func (h *Handler) HandleToolError(ctx context.Context, err error) { + h.endWithError(ctx, err) +} + +// HandleRetrieverStart starts a span for a retriever query. +func (h *Handler) HandleRetrieverStart(ctx context.Context, query string) { + attrs := []attribute.KeyValue{ + attribute.String(attrGenAIOperationName, "retriever"), + } + if h.captureInput { + attrs = append(attrs, attribute.String("retriever.query", query)) + } + _, span := h.tracer.Start(ctx, "retriever.query", + trace.WithSpanKind(trace.SpanKindInternal), + trace.WithAttributes(attrs...), + ) + h.spans.Store(ctx, span) +} + +// HandleRetrieverEnd ends the retriever span. +func (h *Handler) HandleRetrieverEnd(ctx context.Context, _ string, documents []schema.Document) { + span := h.loadAndDelete(ctx) + if span == nil { + return + } + defer span.End() + span.SetAttributes(attribute.Int("retriever.document_count", len(documents))) +} + +// HandleStreamingFunc is intentionally a no-op for tracing. +// Creating a span per chunk would produce extreme noise. +// Use metrics (counters/histograms) if you need streaming observability. +func (h *Handler) HandleStreamingFunc(_ context.Context, _ []byte) {} + +// HandleText is a no-op to satisfy the Handler interface. +func (h *Handler) HandleText(_ context.Context, _ string) {} + +// HandleAgentAction is a no-op to satisfy the Handler interface. +func (h *Handler) HandleAgentAction(_ context.Context, _ schema.AgentAction) {} + +// HandleAgentFinish is a no-op to satisfy the Handler interface. +func (h *Handler) HandleAgentFinish(_ context.Context, _ schema.AgentFinish) {} + +// endWithError is a shared helper: retrieves the active span for ctx, +// records the error, sets span status to Error, and ends the span. +func (h *Handler) endWithError(ctx context.Context, err error) { + span := h.loadAndDelete(ctx) + if span == nil { + return + } + defer span.End() + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + span.SetAttributes(attribute.String(attrErrorType, fmt.Sprintf("%T", err))) + } +} + +// loadAndDelete retrieves and removes the span stored under ctx. +// Returns nil safely if no span was found. +func (h *Handler) loadAndDelete(ctx context.Context) trace.Span { + v, ok := h.spans.LoadAndDelete(ctx) + if !ok { + return nil + } + span, ok := v.(trace.Span) + if !ok { + return nil + } + return span +} + +// toInt converts common numeric types found in GenerationInfo to int. +func toInt(v any) (int, bool) { + switch val := v.(type) { + case int: + return val, true + case int32: + return int(val), true + case int64: + return int(val), true + case float32: + return int(val), true + case float64: + return int(val), true + } + return 0, false +} + +// intStr converts an int to string for use in attribute key construction. +func intStr(i int) string { + return strconv.Itoa(i) +} diff --git a/callbacks/otel/handler_test.go b/callbacks/otel/handler_test.go new file mode 100644 index 000000000..8bdb05b9b --- /dev/null +++ b/callbacks/otel/handler_test.go @@ -0,0 +1,252 @@ +package otelcallback_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + otelcallback "github.com/tmc/langchaingo/callbacks/otel" + "github.com/tmc/langchaingo/llms" + "github.com/tmc/langchaingo/schema" +) + +// newTestProvider returns an in-memory TracerProvider and its SpanRecorder. +func newTestProvider() (*sdktrace.TracerProvider, *tracetest.SpanRecorder) { + recorder := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)) + return tp, recorder +} + +// TestHandlerImplementsInterface is a compile-time + runtime check. +func TestHandlerImplementsInterface(t *testing.T) { + t.Parallel() + tp, _ := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + // runtime interface check + var _ interface{ HandleText(context.Context, string) } = h + assert.NotNil(t, h) +} + +// TestLLMGenerateContentStartCreatesSpan checks that a span is started. +func TestLLMGenerateContentStartCreatesSpan(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleLLMGenerateContentStart(ctx, []llms.MessageContent{ + {Role: llms.ChatMessageTypeHuman, Parts: []llms.ContentPart{llms.TextContent{Text: "hello"}}}, + }) + + // End the span by calling End + h.HandleLLMGenerateContentEnd(ctx, &llms.ContentResponse{ + Choices: []*llms.ContentChoice{ + {Content: "world", GenerationInfo: map[string]any{"InputTokens": 5, "OutputTokens": 3}}, + }, + }) + + spans := recorder.Ended() + require.Len(t, spans, 1) + assert.Equal(t, "llm.generate_content", spans[0].Name()) + assert.Equal(t, codes.Unset, spans[0].Status().Code) + + attrs := spanAttrMap(spans[0]) + assert.Equal(t, "llm.generate_content", attrs["gen_ai.operation.name"]) + assert.Equal(t, int64(5), attrs["gen_ai.usage.input_tokens"]) + assert.Equal(t, int64(3), attrs["gen_ai.usage.output_tokens"]) +} + +// TestLLMGenerateContentEndDoesNotCaptureOutputByDefault checks privacy default. +func TestLLMGenerateContentEndDoesNotCaptureOutputByDefault(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleLLMGenerateContentStart(ctx, nil) + h.HandleLLMGenerateContentEnd(ctx, &llms.ContentResponse{ + Choices: []*llms.ContentChoice{{Content: "secret output"}}, + }) + + spans := recorder.Ended() + require.Len(t, spans, 1) + attrs := spanAttrMap(spans[0]) + _, captured := attrs["gen_ai.completion"] + assert.False(t, captured, "completion must not be captured by default") +} + +// TestLLMGenerateContentCapturesOutputWhenEnabled checks the opt-in capture. +func TestLLMGenerateContentCapturesOutputWhenEnabled(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler( + otelcallback.WithTracerProvider(tp), + otelcallback.WithCaptureOutput(true), + ) + + ctx := context.Background() + h.HandleLLMGenerateContentStart(ctx, nil) + h.HandleLLMGenerateContentEnd(ctx, &llms.ContentResponse{ + Choices: []*llms.ContentChoice{{Content: "explicit output"}}, + }) + + spans := recorder.Ended() + require.Len(t, spans, 1) + attrs := spanAttrMap(spans[0]) + assert.Equal(t, "explicit output", attrs["gen_ai.completion"]) +} + +// TestLLMErrorSetsSpanStatusError checks error recording. +func TestLLMErrorSetsSpanStatusError(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleLLMGenerateContentStart(ctx, nil) + h.HandleLLMError(ctx, errors.New("llm failed")) + + spans := recorder.Ended() + require.Len(t, spans, 1) + assert.Equal(t, codes.Error, spans[0].Status().Code) + assert.Equal(t, "llm failed", spans[0].Status().Description) + assert.NotEmpty(t, spans[0].Events(), "error event should be recorded") +} + +// TestToolErrorSetsSpanStatusError checks tool error recording. +func TestToolErrorSetsSpanStatusError(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleToolStart(ctx, "some input") + h.HandleToolError(ctx, errors.New("tool exploded")) + + spans := recorder.Ended() + require.Len(t, spans, 1) + assert.Equal(t, "tool.call", spans[0].Name()) + assert.Equal(t, codes.Error, spans[0].Status().Code) +} + +// TestRetrieverEndRecordsDocumentCount checks retriever span attributes. +func TestRetrieverEndRecordsDocumentCount(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleRetrieverStart(ctx, "find docs") + h.HandleRetrieverEnd(ctx, "find docs", []schema.Document{{PageContent: "doc1"}, {PageContent: "doc2"}}) + + spans := recorder.Ended() + require.Len(t, spans, 1) + assert.Equal(t, "retriever.query", spans[0].Name()) + attrs := spanAttrMap(spans[0]) + assert.Equal(t, int64(2), attrs["retriever.document_count"]) +} + +// TestNilInputsDoNotPanic verifies the handler is nil-safe. +func TestNilInputsDoNotPanic(t *testing.T) { + t.Parallel() + tp, _ := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + ctx := context.Background() + + assert.NotPanics(t, func() { + h.HandleLLMStart(ctx, nil) + h.HandleLLMError(ctx, nil) + }) + assert.NotPanics(t, func() { + h.HandleLLMGenerateContentStart(ctx, nil) + h.HandleLLMGenerateContentEnd(ctx, nil) + }) + assert.NotPanics(t, func() { + h.HandleChainStart(ctx, nil) + h.HandleChainEnd(ctx, nil) + }) + assert.NotPanics(t, func() { + h.HandleToolStart(ctx, "") + h.HandleToolEnd(ctx, "") + }) + assert.NotPanics(t, func() { + h.HandleRetrieverStart(ctx, "") + h.HandleRetrieverEnd(ctx, "", nil) + }) + assert.NotPanics(t, func() { + h.HandleStreamingFunc(ctx, nil) + }) +} + +// TestEndCalledWithoutStartDoesNotPanic checks orphan End calls are safe. +func TestEndCalledWithoutStartDoesNotPanic(t *testing.T) { + t.Parallel() + tp, _ := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + ctx := context.Background() + + assert.NotPanics(t, func() { h.HandleLLMGenerateContentEnd(ctx, nil) }) + assert.NotPanics(t, func() { h.HandleLLMError(ctx, errors.New("orphan")) }) + assert.NotPanics(t, func() { h.HandleChainEnd(ctx, nil) }) + assert.NotPanics(t, func() { h.HandleChainError(ctx, errors.New("orphan")) }) + assert.NotPanics(t, func() { h.HandleToolEnd(ctx, "") }) + assert.NotPanics(t, func() { h.HandleToolError(ctx, errors.New("orphan")) }) + assert.NotPanics(t, func() { h.HandleRetrieverEnd(ctx, "", nil) }) +} + +// TestCustomTracerProviderIsUsed verifies the option is respected. +func TestCustomTracerProviderIsUsed(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + + ctx := context.Background() + h.HandleChainStart(ctx, nil) + h.HandleChainEnd(ctx, nil) + + assert.Len(t, recorder.Ended(), 1, "span should appear in the custom provider's recorder") +} + +// TestStreamingDoesNotPanic verifies streaming chunks are silently ignored. +func TestStreamingDoesNotPanic(t *testing.T) { + t.Parallel() + tp, recorder := newTestProvider() + h := otelcallback.NewHandler(otelcallback.WithTracerProvider(tp)) + ctx := context.Background() + + assert.NotPanics(t, func() { + for i := 0; i < 10; i++ { + h.HandleStreamingFunc(ctx, []byte("chunk")) + } + }) + assert.Empty(t, recorder.Ended(), "streaming must not create spans") +} + +// spanAttrMap converts a recorded span's attributes to a map for easy assertion. +func spanAttrMap(span sdktrace.ReadOnlySpan) map[string]interface{} { + m := make(map[string]interface{}) + for _, kv := range span.Attributes() { + switch kv.Value.Type() { + case attribute.STRING: + m[string(kv.Key)] = kv.Value.AsString() + case attribute.INT64: + m[string(kv.Key)] = kv.Value.AsInt64() + case attribute.BOOL: + m[string(kv.Key)] = kv.Value.AsBool() + case attribute.FLOAT64: + m[string(kv.Key)] = kv.Value.AsFloat64() + } + } + return m +} + +// ensure trace import is used (for SpanKind constant visibility in test file) +var _ = trace.SpanKindClient diff --git a/callbacks/otel/options.go b/callbacks/otel/options.go new file mode 100644 index 000000000..0282b772c --- /dev/null +++ b/callbacks/otel/options.go @@ -0,0 +1,38 @@ +package otelcallback + +import "go.opentelemetry.io/otel/trace" + +// Option is a functional option for configuring the OpenTelemetry Handler. +type Option func(*config) + +type config struct { + tracerProvider trace.TracerProvider + captureInput bool + captureOutput bool +} + +// WithTracerProvider sets a custom TracerProvider. +// If not called, the global OpenTelemetry TracerProvider is used. +func WithTracerProvider(tp trace.TracerProvider) Option { + return func(c *config) { + c.tracerProvider = tp + } +} + +// WithCaptureInput enables capturing prompt/input content as span attributes. +// WARNING: enabling this may log sensitive user data into your trace backend. +// Default: false. +func WithCaptureInput(capture bool) Option { + return func(c *config) { + c.captureInput = capture + } +} + +// WithCaptureOutput enables capturing completion/output content as span attributes. +// WARNING: enabling this may log sensitive model output into your trace backend. +// Default: false. +func WithCaptureOutput(capture bool) Option { + return func(c *config) { + c.captureOutput = capture + } +} diff --git a/examples/otel-callback-example/go.mod b/examples/otel-callback-example/go.mod new file mode 100644 index 000000000..dae98d8ce --- /dev/null +++ b/examples/otel-callback-example/go.mod @@ -0,0 +1,25 @@ +module github.com/tmc/langchaingo/examples/otel-callback-example + +go 1.25.0 + +require ( + github.com/tmc/langchaingo v0.0.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 + go.opentelemetry.io/otel/sdk v1.44.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dlclark/regexp2 v1.10.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pkoukk/tiktoken-go v0.1.6 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + golang.org/x/sys v0.45.0 // indirect +) + +replace github.com/tmc/langchaingo => ../../ diff --git a/examples/otel-callback-example/go.sum b/examples/otel-callback-example/go.sum new file mode 100644 index 000000000..eb8b21575 --- /dev/null +++ b/examples/otel-callback-example/go.sum @@ -0,0 +1,47 @@ +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0= +github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw= +github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/examples/otel-callback-example/main.go b/examples/otel-callback-example/main.go new file mode 100644 index 000000000..5323dc675 --- /dev/null +++ b/examples/otel-callback-example/main.go @@ -0,0 +1,82 @@ +// otel-callback-example demonstrates how to use the OpenTelemetry callback +// handler with LangChainGo. It uses an in-memory exporter so you can see +// the spans printed to stdout without any external collector. +package main + +import ( + "context" + "fmt" + "log" + + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + + otelcallback "github.com/tmc/langchaingo/callbacks/otel" + "github.com/tmc/langchaingo/llms" +) + +func main() { + // 1. Set up a stdout OTel exporter (prints JSON spans to stdout). + exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint()) + if err != nil { + log.Fatal(err) + } + + // 2. Build a TracerProvider with the stdout exporter. + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName("langchaingo-otel-example"), + )), + ) + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.Fatal(err) + } + }() + + // 3. Create the OTel callback handler, passing in our TracerProvider. + handler := otelcallback.NewHandler( + otelcallback.WithTracerProvider(tp), + // otelcallback.WithCaptureInput(true), // uncomment to capture prompts + // otelcallback.WithCaptureOutput(true), // uncomment to capture completions + ) + + ctx := context.Background() + + // 4. Simulate the lifecycle events that LangChainGo would fire. + // In real usage you pass handler to an LLM or chain via its callbacks option. + fmt.Println("=== Simulating LLM GenerateContent lifecycle ===") + + handler.HandleLLMGenerateContentStart(ctx, []llms.MessageContent{ + llms.TextParts(llms.ChatMessageTypeHuman, "What is the capital of France?"), + }) + + handler.HandleLLMGenerateContentEnd(ctx, &llms.ContentResponse{ + Choices: []*llms.ContentChoice{ + { + Content: "Paris", + StopReason: "stop", + GenerationInfo: map[string]any{ + "InputTokens": 10, + "OutputTokens": 3, + }, + }, + }, + }) + + fmt.Println("\n=== Simulating Tool lifecycle ===") + + handler.HandleToolStart(ctx, "search: Paris weather") + handler.HandleToolEnd(ctx, "Sunny, 22°C") + + fmt.Println("\n=== Simulating Retriever lifecycle ===") + + handler.HandleRetrieverStart(ctx, "capital of France") + handler.HandleRetrieverEnd(ctx, "capital of France", nil) + + fmt.Println("\nDone. Spans printed above.") +} From cdf35fd255b539dea1ecfa56dbc104877b38eb81 Mon Sep 17 00:00:00 2001 From: Prajwal Raymond Moras Date: Wed, 17 Jun 2026 22:09:19 +0530 Subject: [PATCH 2/2] callbacks/otel: remove unused intStr helper and strconv import --- callbacks/otel/handler.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/callbacks/otel/handler.go b/callbacks/otel/handler.go index 179540735..8d3eeddb9 100644 --- a/callbacks/otel/handler.go +++ b/callbacks/otel/handler.go @@ -9,7 +9,6 @@ package otelcallback import ( "context" "fmt" - "strconv" "sync" "go.opentelemetry.io/otel" @@ -267,8 +266,3 @@ func toInt(v any) (int, bool) { } return 0, false } - -// intStr converts an int to string for use in attribute key construction. -func intStr(i int) string { - return strconv.Itoa(i) -}