langfuse

package module
v0.5.0 Latest Latest
Warning

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

Go to latest
Published: Jul 23, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

README

go-langfuse

Go Reference CI License

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, or guardrail, 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.

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()
		_ = 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:

  1. 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 rootCtx and generationCtx.
  2. End an observation only after its work is complete. For streaming model calls, consume the stream before ending the generation.
  3. 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:

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.

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.

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)
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.

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, 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.

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.

Documentation

Overview

Package langfuse provides observation-centric Langfuse tracing on top of OpenTelemetry.

It exports OTLP/HTTP protobuf traces to Langfuse and can either own an isolated tracer provider or attach a Langfuse processor to an existing OpenTelemetry SDK tracer provider through Config. The package never changes global OpenTelemetry state.

Construct a Client with New, typically from ConfigFromEnv, stamp request-scoped identity with Client.WithTraceAttributes, and record work with Client.Observe:

lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
if err != nil {
	return err
}
defer lf.Shutdown(shutdownCtx)

err = lf.Observe(ctx, "answer-question", langfuse.TypeGeneration,
	langfuse.ObservationAttributes{Input: question},
	func(ctx context.Context, o *langfuse.Observation) error {
		answer, err := callModel(ctx, question)
		o.Update(langfuse.ObservationAttributes{Output: answer})
		return err
	})

Client.StartObservation is the lower-level pair for lifetimes that span functions; the context it returns carries the parent-child relationship, so observations started from it nest under their parent. Client.RecordScore submits evaluations and user feedback through the Langfuse JSON ingestion endpoint, Client.GetPrompt loads prompt-management prompts with client-side caching and an optional local fallback, and Client.Flush and Client.Shutdown control the export lifecycle. Whole traces can be sampled deterministically by trace ID through Config.SampleRate and Client.WithSampleRate, with TraceSampledAt exposing the same decision for correlated application-level sampling. A nil or disabled Client and the zero Observation are safe no-ops.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrPromptNotFound = errors.New("langfuse: prompt not found")

ErrPromptNotFound reports that the requested prompt name, version, or label does not exist in the Langfuse project. Test with errors.Is.

View Source
var ErrPromptTypeMismatch = errors.New("langfuse: prompt type mismatch")

ErrPromptTypeMismatch reports that a resolved prompt did not have the type requested by PromptQuery.Type. Test with errors.Is.

Functions

func TraceSampledAt added in v0.4.0

func TraceSampledAt(traceID string, fraction float64) (bool, error)

TraceSampledAt reports whether the trace identified by the 32-character lowercase hex traceID falls inside fraction under the SDK's deterministic threshold scheme, the same scheme the isolated-mode sampler uses, shared with OpenTelemetry's TraceIDRatioBased. A trace selected at a smaller fraction is always selected at any larger one, so work gated at a small fraction (an expensive evaluation on 2% of traces) runs only on traces that a larger export fraction also kept. It returns an error for a malformed trace ID or a fraction that is not finite or not within [0, 1]; it never guesses. It is a pure predicate on the trace ID and does not know what rate the trace was actually sampled at: when gating work on an existing observation, gate on Observation.Sampled and on the returned decision together, after checking the error.

Example
package main

import (
	"fmt"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	// TraceSampledAt is deterministic: every caller agrees on the same trace,
	// and a trace selected at a smaller fraction is always selected at a
	// larger one. Gating an expensive evaluation at 2% therefore evaluates
	// only traces that an export fraction of at least 2% also kept.
	traceID := "0af7651916cd43dd8448eb211c80319c"

	quarter, err := langfuse.TraceSampledAt(traceID, 0.25)
	if err != nil {
		log.Fatal(err)
	}
	majority, err := langfuse.TraceSampledAt(traceID, 0.6)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(quarter, majority)
}
Output:
false true

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client owns all Langfuse exporter, processor, and lifecycle state. Its zero value is a safe no-op.

func New

func New(ctx context.Context, cfg Config) (*Client, error)

New constructs a client and validates configuration without performing network I/O. A disabled configuration bypasses all other validation.

Example
package main

import (
	"context"
	"log"
	"time"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.Config{
		BaseURL:     "https://cloud.langfuse.com",
		PublicKey:   "pk-lf-...",
		SecretKey:   "sk-lf-...",
		Environment: "production",
	})
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = lf.Shutdown(shutdownCtx)
	}()

	_, observation := lf.StartObservation(ctx, "nightly-import", langfuse.TypeSpan,
		langfuse.ObservationAttributes{Input: "42 records"})
	observation.End()
}

func (*Client) Event

func (c *Client) Event(ctx context.Context, name string, values ObservationAttributes)

Event records an instantaneous event observation.

func (*Client) Flush

func (c *Client) Flush(ctx context.Context) error

Flush exports all ended observations and delivers every score accepted before the call; scores recorded after Flush begins do not extend the wait. A score mid-retry holds Flush until it is delivered or dropped, bounded by ctx.

Example
package main

import (
	"context"
	"log"
	"time"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	lf.Event(ctx, "job-completed", langfuse.ObservationAttributes{
		Metadata: map[string]any{"records": 42},
	})

	// Short-lived jobs and serverless handlers flush before returning so
	// batched observations are exported while the client stays usable.
	flushCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := lf.Flush(flushCtx); err != nil {
		log.Printf("flush: %v", err)
	}
}

func (*Client) GetPrompt added in v0.4.0

func (c *Client) GetPrompt(ctx context.Context, name string, query PromptQuery) (Prompt, error)

GetPrompt is safe to call on a nil, disabled, or shut-down client; with a Fallback set it returns that fallback, so callers need no nil guard.

GetPrompt fetches one prompt version from the Langfuse prompts API, serving it from the client-side cache when fresh. An expired entry is returned immediately while one background refresh per prompt runs; a cache miss fetches synchronously, bounded by ctx and a 10-second fetch budget. If the fetch fails for any reason other than ctx ending, query.Fallback is returned when set; otherwise the error carries at most the prompt name and HTTP status, and wraps ErrPromptNotFound for 404. A result whose type does not match query.Type likewise resolves to the fallback or wraps ErrPromptTypeMismatch. A nil ctx or invalid query is always an error.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	// Client setup may be optional in an application. GetPrompt remains safe
	// when the client is nil, so the call site needs no separate nil branch.
	var lf *langfuse.Client
	prompt, err := lf.GetPrompt(context.Background(), "response-template", langfuse.PromptQuery{
		Type:     langfuse.PromptTypeText,
		Fallback: &langfuse.PromptFallback{Text: "Process {{input}}."},
	})
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(prompt.Text)
	fmt.Println(prompt.Source)
}
Output:
Process {{input}}.
fallback

func (*Client) Observe

func (c *Client) Observe(
	ctx context.Context,
	name string,
	observationType ObservationType,
	values ObservationAttributes,
	fn func(ctx context.Context, observation *Observation) error,
) error

Observe starts an observation, runs fn with the child context and the observation handle, and always ends the observation, including when fn panics. A non-nil error returned by fn is recorded through Observation.RecordError and returned unchanged, so fn does not need to record it itself. When fn panics, the observation is marked failed with the payload-free status "panic", the panic value is never captured, and the panic propagates. On a nil, disabled, or stopped client fn still runs, receiving ctx unchanged and a no-op handle. A nil fn reports a diagnostic and starts no observation.

Example
package main

import (
	"context"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	err = lf.Observe(ctx, "retrieve-documents", langfuse.TypeRetriever,
		langfuse.ObservationAttributes{Input: "safe api design"},
		func(ctx context.Context, observation *langfuse.Observation) error {
			// Child observations started from ctx nest under this one. The
			// observation always ends, a returned error is recorded on it,
			// and a panic is marked as a failure before it propagates.
			documents, err := retrieve(ctx, "safe api design")
			if err != nil {
				return err
			}
			observation.Update(langfuse.ObservationAttributes{Output: documents})
			return nil
		})
	if err != nil {
		log.Printf("retrieve: %v", err)
	}
}

func retrieve(ctx context.Context, query string) ([]string, error) {
	if err := ctx.Err(); err != nil {
		return nil, err
	}
	return []string{"doc-" + query}, nil
}

func (*Client) RecordScore

func (c *Client) RecordScore(ctx context.Context, score Score) error

RecordScore submits one score through the Langfuse JSON ingestion endpoint using the client's credentials and environment. The score is validated synchronously, so every returned error marks a score that was not accepted, and then queued for asynchronous delivery with bounded retry (network errors, HTTP 408, 429, and 5xx responses, and per-item ingestion errors with those statuses, using the same backoff defaults as observation export), so transport failures never reach the caller: after the retry budget they are reported as payload-free OpenTelemetry diagnostics and the score is dropped. Client.Flush and Client.Shutdown drain accepted scores. When the queue is full a score is dropped with a diagnostic unless Config.BlockOnQueueFull waits for space, bounded by ctx. A disabled client returns nil without sending, and a shut-down client returns an error. The complete serialized score event is limited to 128 KiB.

Example
package main

import (
	"context"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	rating := 4.0
	err = lf.RecordScore(ctx, langfuse.Score{
		ID:           "feedback-42", // idempotent upsert key
		Name:         "user-feedback",
		SessionID:    "conversation-456",
		NumericValue: &rating,
		Comment:      "clear and concise answer",
	})
	if err != nil {
		log.Printf("record score: %v", err)
	}
}

func (*Client) Shutdown

func (c *Client) Shutdown(ctx context.Context) error

Shutdown permanently stops this Client after draining queued observations and scores, bounded by ctx. In borrowed mode it shuts down the Langfuse processor using ctx before unregistering it; the provider and all unrelated processors remain owned by the application.

func (*Client) StartObservation

func (c *Client) StartObservation(
	ctx context.Context,
	name string,
	observationType ObservationType,
	values ObservationAttributes,
) (context.Context, *Observation)

StartObservation starts an observation. Child work must use the returned context to preserve the parent-child relationship.

Example
package main

import (
	"context"
	"fmt"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	// StartObservation suits lifetimes that span functions, such as ending a
	// generation only after a stream is fully consumed.
	generationCtx, generation := lf.StartObservation(ctx, "generate-answer",
		langfuse.TypeGeneration, langfuse.ObservationAttributes{
			Model: "gemini-3.6-flash",
			Input: "What is context in Go?",
		})
	defer generation.End()

	answer, usage, err := callModel(generationCtx, "What is context in Go?")
	if err != nil {
		generation.RecordError(err)
		return
	}
	generation.Update(langfuse.ObservationAttributes{Output: answer, Usage: &usage})
}

func callModel(ctx context.Context, question string) (string, langfuse.Usage, error) {
	if err := ctx.Err(); err != nil {
		return "", langfuse.Usage{}, err
	}
	return fmt.Sprintf("answer to %q", question), langfuse.Usage{
		InputTokens:  6,
		OutputTokens: 8,
	}, nil
}

func (*Client) WithSampleRate added in v0.4.0

func (c *Client) WithSampleRate(ctx context.Context, fraction float64) context.Context

WithSampleRate returns a context that overrides the configured sample rate for the sampling decision made by the first SDK observation subsequently started on this context path. The fraction must be finite and within [0, 1]; 0 exports nothing, 1 exports everything. The decision is inherited by every SDK observation started from that observation's returned context (and inside its Client.Observe callback), so a rate set later on that path has no effect on the already-decided trace. Sibling observations started independently from a context that carries no decision yet re-decide deterministically from the trace ID and their own effective rate: with equal rates they always agree; setting different rates inside one trace makes trace membership subtree-scoped. Set the rate once per request, before the first observation, unless subtree-scoped rates are intended. An invalid fraction is ignored with a diagnostic, as is any call on a borrowed-provider client, where the application's sampler remains authoritative.

Example
package main

import (
	"context"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	// Set the rate once per request, before the first observation. The whole
	// trace is then kept or dropped together, so a high-volume path can run
	// at 2% while other request types keep the configured default.
	ctx = lf.WithSampleRate(ctx, 0.02)

	observationCtx, observation := lf.StartObservation(ctx, "classify-request",
		langfuse.TypeGeneration, langfuse.ObservationAttributes{})
	defer observation.End()

	if observation.Sampled() {
		// Attach expensive payloads only when the trace is kept for export.
		observation.Update(langfuse.ObservationAttributes{Input: "large payload"})
	}
	_ = observationCtx
}

func (*Client) WithTraceAttributes

func (c *Client) WithTraceAttributes(ctx context.Context, values TraceAttributes) context.Context

WithTraceAttributes returns a context that propagates request-scoped trace fields to observations subsequently started from it. If an SDK observation belonging to this Client is active, it is updated immediately as well.

Example
package main

import (
	"context"
	"log"

	"github.com/fgn/go-langfuse"
)

func main() {
	ctx := context.Background()
	lf, err := langfuse.New(ctx, langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}

	// Stamp request-scoped identity once; observations started from the
	// returned context share the same trace name, user, session, and tags.
	ctx = lf.WithTraceAttributes(ctx, langfuse.TraceAttributes{
		Name:      "chat-turn",
		UserID:    "user-123",
		SessionID: "conversation-456",
		Tags:      []string{"chat"},
	})

	observationCtx, observation := lf.StartObservation(ctx, "chat-turn",
		langfuse.TypeAgent, langfuse.ObservationAttributes{})
	defer observation.End()
	_ = observationCtx
}

type Config

type Config struct {
	// BaseURL is the Langfuse host or OTLP traces endpoint. It defaults to
	// https://cloud.langfuse.com.
	BaseURL string
	// PublicKey identifies the Langfuse project.
	PublicKey string
	// SecretKey authenticates writes to the Langfuse project.
	SecretKey string

	// Environment and Release are stamped on every span observed by the
	// Langfuse processor.
	Environment string
	Release     string

	// SampleRate selects the fraction of traces exported in isolated mode,
	// decided once per trace by a deterministic threshold on the trace ID and
	// inherited by every SDK observation started on the deciding context
	// path. nil selects the default of 1.0 (export everything); a non-nil
	// value must be finite and within [0, 1], where 0 exports no traces while
	// scores and prompts keep working. Other values are a validation error in
	// [New]. It is ignored with a diagnostic when TracerProvider is set,
	// where the application's sampler remains authoritative.
	// [Client.WithSampleRate] overrides it per context path.
	SampleRate *float64

	// ServiceName overrides service.name on an SDK-owned provider. When empty,
	// the standard OpenTelemetry resource.Default value is retained, including
	// OTEL_SERVICE_NAME. It is ignored when TracerProvider is set.
	ServiceName string

	// TracerProvider attaches the Langfuse processor to a caller-owned provider. The client
	// never replaces the global provider or shuts this provider down.
	TracerProvider *sdktrace.TracerProvider

	// MaxQueueSize bounds how many ended export-eligible spans the client
	// buffers in memory while waiting for batch export. Zero selects the
	// default of 2048; negative values are a validation error in [New].
	MaxQueueSize int

	// BlockOnQueueFull makes ending an exported observation, and recording a
	// score, wait for buffer space instead of dropping when the
	// corresponding queue is full. A sustained export outage can then stall
	// goroutines that end observations or record scores, so it defaults to
	// false: on a full queue new work is dropped with a diagnostic, matching
	// OpenTelemetry defaults.
	BlockOnQueueFull bool

	// Disabled makes the complete client a safe no-op.
	Disabled bool

	// DisableContentCapture removes Input and Output supplied through
	// ObservationAttributes. It does not remove content emitted by third-party
	// OpenTelemetry instrumentation. Identifiers, metadata, model data, and
	// usage are still recorded.
	DisableContentCapture bool

	// Mask applies only to Input, Output, and Metadata supplied through this
	// Client. Each metadata map is passed as one complete value and must remain
	// a map[string]any to be retained. It does not process identifiers, model
	// fields, StatusMessage, [Observation.RecordError] text, or third-party
	// spans and events.
	// Mask may be called concurrently and therefore must be concurrency-safe.
	// A panic is recovered and the affected value is omitted.
	Mask func(value any) any
	// contains filtered or unexported fields
}

Config configures a Langfuse client.

func ConfigFromEnv

func ConfigFromEnv() Config

ConfigFromEnv returns configuration from the eight documented LANGFUSE_* variables. It intentionally does not read the deprecated LANGFUSE_HOST.

Example
package main

import (
	"context"
	"log"
	"time"

	"github.com/fgn/go-langfuse"
)

func main() {
	// ConfigFromEnv reads LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY,
	// LANGFUSE_BASE_URL, LANGFUSE_TRACING_ENVIRONMENT, LANGFUSE_RELEASE,
	// LANGFUSE_SAMPLE_RATE, LANGFUSE_TRACING_ENABLED, and
	// LANGFUSE_CONTENT_CAPTURE_ENABLED.
	lf, err := langfuse.New(context.Background(), langfuse.ConfigFromEnv())
	if err != nil {
		log.Fatal(err)
	}
	defer func() {
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		_ = lf.Shutdown(shutdownCtx)
	}()
}

type Level

type Level string

Level is the Langfuse severity of an observation.

const (
	LevelDefault Level = "DEFAULT"
	LevelDebug   Level = "DEBUG"
	LevelWarning Level = "WARNING"
	LevelError   Level = "ERROR"
)

type Observation

type Observation struct {
	// contains filtered or unexported fields
}

Observation is a concurrency-safe handle around one OpenTelemetry span. Its zero value is a safe no-op.

func (*Observation) End

func (o *Observation) End()

End ends the observation exactly once at the current time.

func (*Observation) EndAt added in v0.4.0

func (o *Observation) EndAt(at time.Time)

EndAt ends the observation exactly once at the provided time. It exists for instrumentation that records work after the fact: pair it with ObservationAttributes.StartTime and CompletionStartTime to reproduce the observed timeline. A zero time falls back to the current time with a diagnostic, and an end time before an explicitly supplied StartTime is replaced by that start time with a diagnostic. Only the first End or EndAt call takes effect; later calls are ignored.

func (*Observation) ID

func (o *Observation) ID() string

ID returns the lowercase 16-character OpenTelemetry span ID, or an empty string for a no-op observation.

func (*Observation) RecordError

func (o *Observation) RecordError(err error)

RecordError records an exception and marks the observation as failed. It does not end the observation. At most eight exception events are retained; later calls are omitted with one diagnostic. The error text is explicitly supplied content and is not processed by Config.Mask. Invalid UTF-8 or text over 64 KiB is replaced by the payload-free string "error".

func (*Observation) Sampled added in v0.4.0

func (o *Observation) Sampled() bool

Sampled reports whether this observation's trace was selected for export by sampling. It reports false on a no-op observation. It is a sampling decision, not a delivery guarantee: exporting still requires End, a live client, and export success. Use it to skip work whose only purpose is enriching an observation that will never be exported, together with TraceSampledAt when gating on a smaller correlated fraction.

func (*Observation) TraceID

func (o *Observation) TraceID() string

TraceID returns the lowercase 32-character OpenTelemetry trace ID, or an empty string for a no-op observation.

func (*Observation) Update

func (o *Observation) Update(values ObservationAttributes)

Update merges non-zero fields into an active observation. Structured model, usage, and cost maps replace their complete serialized attributes when set; metadata merges by top-level key. StartTime cannot be changed after start and is ignored with a diagnostic.

type ObservationAttributes

type ObservationAttributes struct {
	// Input and Output are explicit content. They are never inferred by the
	// SDK, are subject to Config.DisableContentCapture, and pass through Mask.
	Input  any
	Output any
	// Metadata merges by top-level key across Update calls and passes through
	// Mask. Empty maps are ignored and cannot clear earlier metadata. At most 32
	// distinct keys are retained over an observation's lifetime; keys are
	// limited to 200 bytes.
	Metadata map[string]any
	Level    Level
	// StatusMessage is explicit telemetry content, is limited to 16 KiB, and
	// does not pass through Mask. Sanitize it before calling the SDK.
	StatusMessage string
	// Version overrides a propagated TraceAttributes.Version on this
	// observation only.
	Version string

	// The remaining fields are honored only by generation and embedding
	// observations, except Prompt, which Langfuse links only on generations.
	Model               string
	ModelParameters     map[string]any
	Usage               *Usage
	CostDetails         map[string]float64
	Prompt              *PromptRef
	CompletionStartTime time.Time

	// StartTime is honored only by StartObservation; Event and Update ignore
	// it with a diagnostic.
	StartTime time.Time
}

ObservationAttributes contains fields that can be set when an observation starts or in a later Update. Zero values are ignored and updates never clear an already recorded value. Each serialized structured value is limited to 1 MiB and the observation-level payload attributes are limited to 2 MiB in aggregate over the observation's current values. Separately bounded trace/client propagation and exception events sit outside that aggregate. Oversized fields are omitted with a payload-free OpenTelemetry diagnostic.

type ObservationType

type ObservationType string

ObservationType identifies how Langfuse presents an observation.

const (
	TypeSpan       ObservationType = "span"
	TypeGeneration ObservationType = "generation"
	TypeEvent      ObservationType = "event"
	TypeEmbedding  ObservationType = "embedding"
	TypeAgent      ObservationType = "agent"
	TypeTool       ObservationType = "tool"
	TypeChain      ObservationType = "chain"
	TypeRetriever  ObservationType = "retriever"
	TypeEvaluator  ObservationType = "evaluator"
	TypeGuardrail  ObservationType = "guardrail"
)

type Prompt added in v0.4.0

type Prompt struct {
	Name          string
	Version       int
	Type          PromptType
	Text          string
	Messages      []PromptMessage
	Config        json.RawMessage
	Labels        []string
	Tags          []string
	CommitMessage string
	// Source reports whether this value was fetched, served from fresh or
	// stale cache, or produced from PromptQuery.Fallback.
	Source PromptSource
}

Prompt is one fetched prompt version. GetPrompt returns an independent deep copy (the SDK's cached master is never exposed), so callers may retain or modify the value freely.

func (Prompt) Compile added in v0.4.0

func (p Prompt) Compile(vars map[string]any) Prompt

Compile substitutes {{variable}} occurrences in the prompt content and returns the compiled copy; the receiver is unchanged. String values substitute verbatim; any other value is JSON-encoded, and a conversion that fails or panics leaves that occurrence unresolved; Compile never fails and never panics. For chat prompts, a variable whose value is []PromptMessage fills the placeholder of that name: an empty slice removes the placeholder, and a slice containing any invalid message leaves the placeholder unchanged (never a partial splice). Unresolved variables and unfilled placeholders stay verbatim, matching the Python SDK; non-string values use JSON rather than Python's str().

Example
package main

import (
	"fmt"

	"github.com/fgn/go-langfuse"
)

func main() {
	prompt := langfuse.Prompt{
		Type: langfuse.PromptTypeText,
		Text: "Summarize {{topic}} in one line.",
	}
	compiled := prompt.Compile(map[string]any{"topic": "Go context"})
	fmt.Println(compiled.Text)
}
Output:
Summarize Go context in one line.

func (Prompt) CompileStrict added in v0.4.0

func (p Prompt) CompileStrict(vars map[string]any) (Prompt, error)

CompileStrict compiles the same copy as Compile and also reports missing content variables, values that cannot be stringified, and unfilled chat placeholders. The returned prompt contains every successful substitution even when the error is non-nil.

func (Prompt) DecodeConfig added in v0.4.0

func (p Prompt) DecodeConfig(v any) error

DecodeConfig decodes Config into v. An absent config is a no-op, allowing callers to set defaults on v before decoding prompt-owned overrides.

The prompt config is server-controlled, so a decode failure is reported through payload-free categories that name only the caller-owned target type: the underlying encoding/json error is never wrapped or surfaced, because its text can echo config values or offending bytes into an error callers routinely log.

func (Prompt) Ref added in v0.4.0

func (p Prompt) Ref() *PromptRef

Ref returns the reference that links observations to this prompt version, for ObservationAttributes.Prompt. It returns nil, safely skipping linking, for a fallback prompt and for any value that cannot form a valid reference (empty Name or Version <= 0, such as a zero Prompt).

type PromptFallback added in v0.4.0

type PromptFallback struct {
	// Type declares the prompt shape. Empty infers chat when Messages is
	// non-nil (an empty non-nil slice is a deliberate empty chat prompt) and
	// text otherwise. Explicit text requires Messages == nil; explicit chat
	// requires Text == "".
	Type PromptType
	// Text is the text prompt body.
	Text string
	// Messages is the chat prompt body.
	Messages []PromptMessage
	// Config is surfaced through Prompt.Config; when non-empty it must be
	// valid JSON.
	Config json.RawMessage
}

PromptFallback is a locally supplied prompt body used when a fetch cannot resolve a compatible prompt.

type PromptMessage added in v0.4.0

type PromptMessage struct {
	Role    string
	Content string
	// PlaceholderName marks this message as a named placeholder slot; when
	// set, Role, Content, and Extra must be empty.
	PlaceholderName string
	// Extra preserves additional message fields beyond role and content (for
	// example tool_calls) verbatim, as one JSON object. Compile never
	// substitutes inside Extra.
	Extra json.RawMessage
}

PromptMessage is one chat prompt message. A regular message sets Role (required) and Content (optional; tool-call messages may have empty content); a placeholder message sets only PlaceholderName, a named slot filled at Compile time.

type PromptQuery added in v0.4.0

type PromptQuery struct {
	// Version selects an exact prompt version. Zero means "use Label";
	// negative values are rejected.
	Version int
	// Label selects the deployment label; empty means "production".
	// Version and Label are mutually exclusive.
	Label string
	// Type requires the resolved prompt to have this shape. Empty accepts
	// either text or chat. A mismatch is resolved through Fallback when set
	// and otherwise returns ErrPromptTypeMismatch. Type filters per call: it
	// is not part of the cache key and does not suppress the shared entry's
	// background refresh, so a differently typed caller still keeps the entry
	// fresh.
	Type PromptType
	// CacheTTL overrides the 60-second default freshness window for this
	// call. Zero means the default; negative values are rejected. Freshness
	// is judged against the age of the cached entry at call time, so callers
	// with different TTLs share one entry (a deliberate divergence from the
	// official SDKs, which stamp an expiry when the entry is inserted).
	CacheTTL time.Duration
	// DisableCache bypasses the cache entirely for this call (no read, no
	// write, no shared in-flight fetch), forcing a fresh fetch.
	DisableCache bool
	// Fallback is returned when a fetch fails without a cached value or the
	// resolved prompt does not match Type, so a local prompt can guarantee
	// availability. A fallback result is never cached and Ref on it returns
	// nil, so it is not linked to observations. Fallback never overrides the
	// caller's own context cancellation on a blocking fetch path.
	Fallback *PromptFallback
}

PromptQuery selects a prompt version and controls caching. The zero value requests the "production"-labeled version with the default 60-second TTL.

type PromptRef

type PromptRef struct {
	Name    string
	Version int
}

PromptRef links an observation to a versioned Langfuse prompt.

type PromptSource added in v0.4.0

type PromptSource string

PromptSource reports how GetPrompt resolved a prompt.

const (
	PromptSourceServer   PromptSource = "server"
	PromptSourceCache    PromptSource = "cache"
	PromptSourceStale    PromptSource = "stale"
	PromptSourceFallback PromptSource = "fallback"
)

type PromptType added in v0.4.0

type PromptType string

PromptType discriminates text and chat prompts.

const (
	PromptTypeText PromptType = "text"
	PromptTypeChat PromptType = "chat"
)

type Score

type Score struct {
	// ID makes submissions idempotent: Langfuse upserts scores by ID.
	// Optional; the SDK generates a random ID when empty so retried
	// deliveries cannot create duplicates.
	ID string
	// Name identifies the score series, for example "user-feedback".
	// Required; at most 200 characters.
	Name string
	// TraceID, SessionID, and ObservationID select the score target. At
	// least one of TraceID or SessionID is required, and ObservationID
	// additionally requires TraceID.
	TraceID       string
	SessionID     string
	ObservationID string
	// Exactly one of NumericValue or StringValue must be set. Boolean
	// scores use NumericValue 0 or 1 with ScoreTypeBoolean.
	NumericValue *float64
	StringValue  *string
	// DataType is optional; when empty, Langfuse infers NUMERIC or
	// CATEGORICAL from the value type.
	DataType ScoreDataType
	// ConfigID references a Langfuse score config by its identifier.
	// Optional; at most 200 characters. Langfuse validates the score against
	// the config server-side, so a violating score is rejected during
	// asynchronous delivery and dropped with a diagnostic rather than
	// returned as a RecordScore error.
	ConfigID string
	// Comment is explicit content supplied by the caller. It is not
	// processed by Config.Mask; sanitize it before calling the SDK.
	Comment string
	// Metadata is serialized as one JSON value.
	Metadata map[string]any
	// Timestamp records when the scored interaction happened, for example
	// when feedback is computed by a batch job hours after the trace. The
	// zero value stamps the score with the time RecordScore accepted it. The
	// UTC year must stay within the four-digit RFC 3339 range.
	Timestamp time.Time
}

Score is one evaluation or feedback value attached to a trace, a session, or an observation. Scores are submitted through the Langfuse JSON ingestion API rather than the OpenTelemetry trace pipeline.

type ScoreDataType

type ScoreDataType string

ScoreDataType identifies how Langfuse stores and aggregates a score value.

const (
	ScoreTypeBoolean     ScoreDataType = "BOOLEAN"
	ScoreTypeCategorical ScoreDataType = "CATEGORICAL"
	ScoreTypeCorrection  ScoreDataType = "CORRECTION"
	ScoreTypeNumeric     ScoreDataType = "NUMERIC"
	ScoreTypeText        ScoreDataType = "TEXT"
)

type TraceAttributes

type TraceAttributes struct {
	Name      string
	UserID    string
	SessionID string
	// Tags merge in caller order, de-duplicate, and retain at most 64 values
	// and 16 KiB of UTF-8 data over one trace context.
	Tags []string
	// Metadata merges by top-level key and retains at most 32 distinct keys in
	// one trace context so required observation fields remain within default
	// OpenTelemetry span limits. Keys are limited to 200 bytes.
	Metadata map[string]any
	Version  string
}

TraceAttributes are request-scoped fields copied to an active SDK observation and to spans subsequently started from the returned context.

type Usage

type Usage struct {
	InputTokens              int64
	OutputTokens             int64
	CacheReadInputTokens     int64
	CacheCreationInputTokens int64
	ReasoningOutputTokens    int64

	// Details contains additional provider-specific subsets. Keys prefixed
	// input_ or output_ are subtracted from the corresponding base bucket.
	// Such buckets must be mutually exclusive, must not overlap the typed
	// cache/reasoning fields, and must be subsets of their inclusive total. The
	// canonical input, output, total, cache, and reasoning keys are reserved.
	// At most 64 provider-specific detail buckets are retained, with keys
	// limited to 200 bytes.
	Details map[string]int64
}

Usage records inclusive provider token counts. InputTokens includes cache tokens, and OutputTokens includes reasoning tokens. The SDK normalizes these values to Langfuse's exclusive usage_details representation while total remains the checked sum of the inclusive input and output totals.

Directories

Path Synopsis
contrib
googlegenai module
openai module
examples
existingotel command
prompts command
quickstart command
sampling command
scores command
shortlived command
streaming command
internal
attributes
Package attributes owns Langfuse's OpenTelemetry attribute contract.
Package attributes owns Langfuse's OpenTelemetry attribute contract.
diagnostic
Package diagnostic reports payload-free SDK diagnostics through OpenTelemetry.
Package diagnostic reports payload-free SDK diagnostics through OpenTelemetry.
otlpreceiver
Package otlpreceiver provides a local OTLP/HTTP protobuf receiver for tests.
Package otlpreceiver provides a local OTLP/HTTP protobuf receiver for tests.
processor
Package processor contains the Langfuse OpenTelemetry span processor.
Package processor contains the Langfuse OpenTelemetry span processor.

Jump to

Keyboard shortcuts

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