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, lifecycle calls are idempotent, and telemetry failures never become application failures.
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.
go get github.com/fgn/go-langfuseSet 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-hostedLANGFUSE_BASE_URL accepts a host root, /api/public/otel, or the full
traces endpoint. Path-prefixed reverse-proxy base URLs are not supported.
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()
_ = lf.Shutdown(shutdownCtx)
}()
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.
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.
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:
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,
})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.
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.
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.
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)| 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 |
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.
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 AIAttach 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.
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, and Config.Mask 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.
- 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
task ciThis 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.