go-langfuse
August 21, 2026 ยท View on GitHub
An independent, observation-first community Langfuse client for Go, built on the official OpenTelemetry Go SDK. Not affiliated with or endorsed by Langfuse.
- One small API for everything you trace. Every operation is an
observation. Only its type differs:
span,generation,event,agent,tool,chain,retriever,evaluator,embedding, orguardrail, the full set the Langfuse platform defines. - OpenTelemetry-native. Exports OTLP/HTTP protobuf (Langfuse ingestion
version 4). Owns an isolated tracer provider, or attaches to yours and
also exports third-party
gen_ai.*spans. Never touches global OTel state. - Scores and prompts included. Evaluations and user feedback with asynchronous retried delivery; prompt management reads with caching, compilation, and guaranteed-availability fallbacks.
- Deterministic trace sampling. Per-request rates in one process, and a pure predicate for correlated app-level sampling such as gating an expensive LLM-judge evaluation to a subset of the traces kept for export.
- Strict content privacy. Exports only what you explicitly supply; a capture kill-switch and a masking hook cover input, output, and metadata.
- Safe by default. Nil and disabled clients are true no-ops, zero values are safe, repeat lifecycle calls return a stable result, and score validation and score-queue admission failures are explicit. Export failures never escape observation calls.
Datasets and administrative APIs are out of scope; use the Langfuse REST API for those. go-langfuse follows semantic versioning: until v1.0, minor releases may contain documented breaking changes; patch releases are always backward compatible.
Install
go get github.com/fgn/go-langfuse
Set the project credentials from Langfuse -> Settings -> API Keys:
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...
export LANGFUSE_BASE_URL=https://cloud.langfuse.com # or self-hosted
LANGFUSE_BASE_URL accepts a host root, /api/public/otel, or the full
traces endpoint. Path-prefixed reverse-proxy base URLs are not supported.
Quickstart
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/fgn/go-langfuse"
)
func main() {
if err := run(context.Background()); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context) error {
lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
if err != nil {
return fmt.Errorf("create Langfuse client: %w", err)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := lf.Shutdown(shutdownCtx); err != nil {
log.Printf("shut down Langfuse client: %v", err)
}
}()
ctx = lf.WithTraceAttributes(ctx, langfuse.TraceAttributes{
Name: "chat-turn", UserID: "user-123", SessionID: "conversation-456",
Tags: []string{"chat"},
})
question := "What is context in Go?"
rootCtx, root := lf.StartObservation(ctx, "chat-turn", langfuse.TypeAgent,
langfuse.ObservationAttributes{Input: question})
defer root.End()
messages := []string{question}
generationCtx, generation := lf.StartObservation(rootCtx, "generate-answer",
langfuse.TypeGeneration, langfuse.ObservationAttributes{
Model: "gemini-3.6-flash", Input: messages,
})
defer generation.End()
answer, usage, err := callModel(generationCtx, messages)
if err != nil {
generation.RecordError(err)
root.RecordError(err)
return err
}
generation.Update(langfuse.ObservationAttributes{Output: answer, Usage: &usage})
root.Update(langfuse.ObservationAttributes{Output: answer})
return nil
}
// Replace this stub with a provider SDK call and pass ctx to that SDK.
func callModel(ctx context.Context, messages []string) (string, langfuse.Usage, error) {
if err := ctx.Err(); err != nil {
return "", langfuse.Usage{}, err
}
return "Context carries cancellation and request-scoped values.", langfuse.Usage{
InputTokens: int64(len(messages) * 6), OutputTokens: 8,
}, nil
}
Three rules prevent most tracing mistakes:
- Pass the returned context to child work. Go context is the
parent-child relationship; keep parent contexts in distinct variables as
the example does with
rootCtxandgenerationCtx. - End an observation only after its work is complete. For streaming model calls, consume the stream before ending the generation.
- End observations before flushing or shutting down, and give lifecycle calls a fresh timeout context, not a canceled request context.
More runnable examples: quickstart, streaming, prompt management, scores, sampling, existing OpenTelemetry provider, and short-lived jobs, events, masking, disabled mode, and flushing. The main entry points have runnable examples on pkg.go.dev.
Observations
Everything you trace uses the same two calls; only the
observation type
differs. Prefer Observe when the work fits one function. The callback
receives the child context, and the observation always ends, even on a
panic, which is marked as a payload-free failure before it propagates. A
returned error is recorded and passed through unchanged:
err := lf.Observe(ctx, "retrieve-documents", langfuse.TypeRetriever,
langfuse.ObservationAttributes{Input: query},
func(ctx context.Context, o *langfuse.Observation) error {
documents, err := retrieve(ctx, query)
if err != nil {
return err // recorded automatically
}
o.Update(langfuse.ObservationAttributes{Output: documents})
return nil
})
Use StartObservation when a lifetime spans functions, as the quickstart
does; Event records a point in time. Update merges non-zero fields,
RecordError marks a failure without ending, and
StartTime/CompletionStartTime/EndAt reproduce an already-observed
timeline when instrumenting after the fact. For background work that
outlives its request, clear the span context with the standard
OpenTelemetry helper so the work becomes a new trace that keeps the
propagated user and session; the reference shows the
full pattern.
Scores
RecordScore submits evaluations and user feedback. Validation is
synchronous, so every returned error means the score was not accepted.
Delivery is asynchronous with bounded retry, and Flush/Shutdown drain
accepted scores. If the non-blocking score queue is full, RecordScore
returns ErrScoreQueueFull and does not accept the score:
rating := float64(feedback.Rating)
err := lf.RecordScore(ctx, langfuse.Score{
ID: "feedback-" + feedback.ID, // idempotent upsert key
Name: "user-feedback",
SessionID: sessionID, // or TraceID / TraceID+ObservationID
NumericValue: &rating,
Comment: feedback.Text,
})
Set exactly one target: a session, a trace, or an observation with its trace.
When DataType is set, its value must have the matching shape. Numeric and
Boolean scores use NumericValue; Categorical, Text, and Correction scores use
StringValue. Boolean values must be 0 or 1, Text values must contain 1 to 500
characters, and Correction scores can target only a trace or observation and
cannot use a score config.
The SDK generates the upsert ID when ID is empty, so retried deliveries
cannot create duplicates. Timestamp backdates a score from a later
evaluation job, and ConfigID binds it to a Langfuse score config. The
scores example records session, observation, and
trace scores in one runnable program.
Prompts
GetPrompt loads prompt-management prompts with client-side caching. Fresh
hits are local reads. Expired entries are served stale while one background
refresh runs, and concurrent misses share a single fetch. A Fallback
makes prompt loading safe to depend on. It also covers nil, disabled, and
shut-down clients, so optional observability needs no guards:
prompt, err := lf.GetPrompt(ctx, "response-template", langfuse.PromptQuery{
Type: langfuse.PromptTypeText,
Fallback: &langfuse.PromptFallback{Text: "Process {{input}} concisely."},
})
if err != nil {
return err
}
compiled, err := prompt.CompileStrict(map[string]any{"input": input})
if err != nil {
return err
}
_ = lf.Observe(ctx, "generate-response", langfuse.TypeGeneration,
langfuse.ObservationAttributes{Input: compiled.Text, Prompt: prompt.Ref()},
generate)
Selection defaults to the production label; Version or Label pin
others. Compile is lenient, CompileStrict reports unresolved variables,
DecodeConfig applies prompt config to a caller-defaulted struct, and
Ref() links only server-backed versions to generations.
Prompt.Source distinguishes server, cache, stale, and fallback results.
The prompts example runs this flow end to end.
Content capture
Config.DisableContentCapture is the client default for SDK-supplied
observation input and output. WithContentCapture overrides that default on
one client-scoped context tree. Resolve the application policy before the
first observation and pass the returned context to all child work:
cfg := langfuse.ConfigFromEnv()
cfg.DisableContentCapture = true
lf, err := langfuse.New(ctx, cfg)
if err != nil {
return err
}
if contentCaptureAllowed {
ctx = lf.WithContentCapture(ctx, true)
}
ctx, observation := lf.StartObservation(ctx, "generate-answer",
langfuse.TypeGeneration, langfuse.ObservationAttributes{Input: prompt})
observation.Update(langfuse.ObservationAttributes{Output: answer})
observation.End()
Absence of an override retains the client default. A child context inherits the
override, but a context from another client does not. Each observation stores
the effective decision when it starts, so later Update calls use the same
policy. The switch controls only ObservationAttributes.Input and Output;
audit metadata, status and error text, score comments and values, and resource
attributes separately. It does not sanitize third-party OpenTelemetry spans.
Use the default isolated tracer provider when Langfuse observations must not
also reach another telemetry backend.
Sampling
In isolated mode the client samples whole traces deterministically by trace
ID. Config.SampleRate (or LANGFUSE_SAMPLE_RATE) sets the default
fraction; WithSampleRate overrides it per request, so one process can
keep every trace from a critical path while exporting a fraction of
high-volume routine work:
ctx = lf.WithSampleRate(ctx, 0.02) // keep 2% of a high-volume path
ctx, root := lf.StartObservation(ctx, "generate-answer", langfuse.TypeGeneration,
langfuse.ObservationAttributes{Input: prompt})
Set the rate once per request, before the first observation; the whole
trace is then kept or dropped together. Because smaller fractions select
subsets of larger ones, TraceSampledAt can gate an expensive LLM-judge
evaluation at 2% with the guarantee that every evaluated trace was also
kept for export:
keep, err := langfuse.TraceSampledAt(root.TraceID(), 0.02)
if err == nil && keep && root.Sampled() {
verdict := judge(ctx, output)
_ = lf.RecordScore(ctx, langfuse.Score{
Name: "judge", TraceID: root.TraceID(), NumericValue: &verdict,
})
}
Sampled-out observations keep their IDs, become cheap no-ops, and suppress scores recorded on their own context path so dropped traces do not accumulate orphaned scores. In borrowed mode the application's sampler remains authoritative. Decision scope and score semantics are in the reference; the sampling example shows both gates on a simulated high-volume route.
Provider modes
The default mode creates an isolated SDK tracer provider that exports only observations created through this client:
lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
If the application already owns an *sdktrace.TracerProvider, attach the
client as another processor; a smart filter then also exports third-party
AI spans (gen_ai.* attributes and known LLM instrumentation scopes):
cfg := langfuse.ConfigFromEnv()
cfg.TracerProvider = existingProvider
lf, err := langfuse.New(ctx, cfg)
gen_ai.* is the OpenTelemetry GenAI semantic-convention namespace. To
replace the default policy, set ShouldExportSpan before calling New; compose with
IsDefaultExportSpan when extending it:
cfg.ShouldExportSpan = func(span sdktrace.ReadOnlySpan) bool {
return langfuse.IsDefaultExportSpan(span) || span.InstrumentationScope().Name == "my.framework"
}
The filter is a Langfuse export decision, not a sanitizer and not a filter for other processors on the shared provider. Use the isolated default mode when SDK observations or propagated Langfuse attributes must not reach another backend.
| Behavior | Isolated provider | Borrowed provider |
|---|---|---|
| Provider owner | SDK client | Application |
| SDK observations | Exported | Exported |
| Selected third-party AI spans | Not observed | Exported by the SDK's smart filter |
| Sampler and resource | Deterministic trace sampling (default: keep everything); SDK-owned resource | Existing provider remains authoritative |
| Span limits | Fixed SDK-safe limits; ambient OTEL_SPAN_* ignored | Caller limits remain authoritative |
Client.Shutdown | Stops owned provider resources | Stops and unregisters only the SDK's processor |
| Global OTel provider | Never replaced | Never replaced |
The table describes the default filter. A custom ShouldExportSpan is a full
override and can also reject SDK observations.
Neither mode ever changes the global OpenTelemetry provider. Borrowed-mode lifecycle and the one-client-per-provider rule are covered in the existing OpenTelemetry guide.
Provider instrumentation
The core module has no OpenAI and no Google dependencies. Optional
adapter modules instrument provider HTTP calls at the transport, so
native clients (the official openai-go, sashabaranov/go-openai,
google.golang.org/genai) stay exactly as they are. Install an
adapter only when you want it, each versioned independently:
go get github.com/fgn/go-langfuse/contrib/openai # OpenAI wire: OpenAI, Azure, compatibles
go get github.com/fgn/go-langfuse/contrib/googlegenai # Gemini wire: Developer API, Vertex AI
Attach at client construction; call sites do not change:
cfg.HTTPClient = &http.Client{Transport: langfuseopenai.NewTransport(lf, nil)}
That single line replaces the instrumentation you would otherwise write and maintain per provider and per call site: every recognized call records a generation or embedding observation under whatever observation is in the request context, carrying the validated response model, exact token usage (cached and reasoning detail included, and stream usage that arrives after the finish chunk), sanitized input and output (media as placeholders, parallel tool calls as distinct structured calls), time-to-first-token for streams, and a wire-provable status. The adapters parse the wire format, not SDK types, so they carry no provider SDK dependencies and one adapter covers every client speaking that protocol; everything they record flows through this client's masking, capture, sampling, and limit controls.
Runnable end-to-end examples that work without provider credentials: official openai-go streaming chat, sashabaranov/go-openai streaming chat, and Vertex AI with the credentials composition. Scope, retry/metric semantics, and the privacy boundary are documented in the OpenAI adapter README and the Google GenAI adapter README.
Content and sensitive data
The SDK never inspects function arguments, HTTP bodies, or model clients;
it exports only fields explicitly supplied by the caller.
LANGFUSE_CONTENT_CAPTURE_ENABLED=false drops SDK-supplied input and output.
Config.Mask receives a MaskField and transforms input, output, and metadata
before export. Identifiers, model data, status messages, error text, and
third-party spans sit outside both controls; the exact boundary and a masker
example are in the privacy guide.
Documentation
- API reference and examples on pkg.go.dev
- Configuration and behavior reference: environment variables, buffering and backpressure, flush/shutdown, limits, sampling, and current limitations
- Privacy guide: the content-capture and masking boundary
- Existing OpenTelemetry guide: borrowed provider lifecycle
- Compatibility matrix: protocol and server support
- Langfuse documentation
Development
task ci
This checks formatting and module tidiness, runs static analysis, compiles
the examples and README quickstart, and runs the test, fuzz-smoke, and
vulnerability suites. Run task format to apply source and module
formatting. The module language version is Go 1.25; go.mod records the
suggested patched toolchain. Tests never require Langfuse credentials.
Release steps are documented in RELEASING.md.