OpenTelemetry-based Langfuse tracing for Java, Spring AI, and LangChain4j.
Quick Start · What gets traced · Features · Compatibility · Migration · Roadmap
The core module provides a small synchronous tracing API. The Spring Boot starter instruments supported Spring AI and LangChain4j calls and can either reuse the application's OpenTelemetry SDK or own a dedicated Langfuse exporter.
Release status:
0.2.xis the production-preview line for Spring Boot 3, Spring AI 1, and LangChain4j. The dependency examples below use0.2.0.
<dependency>
<groupId>io.github.chomingi</groupId>
<artifactId>langfuse-otel-spring-boot-starter</artifactId>
<version>0.2.0</version>
</dependency>Spring AI and LangChain4j are optional integrations. The starter does not choose an LLM framework or provider for the application, so keep the framework and provider dependency that supplies your model bean. The example projects use OpenAI, but no provider client is pulled in transitively.
# application.yml
langfuse:
otel-mode: standalone # create and own a dedicated Langfuse exporter
public-key: ${LANGFUSE_PUBLIC_KEY}
secret-key: ${LANGFUSE_SECRET_KEY}
host: https://cloud.langfuse.com # or your self-hosted URL
content:
capture-input: false # safe default
capture-output: false # safe defaultThis quick start deliberately selects standalone, so the configured keys and host are used by a dedicated SDK/exporter. For production applications that already own OpenTelemetry, use the external setup below instead of creating a second SDK.
Standalone endpoints must use HTTPS. For a loopback development receiver only, plaintext HTTP can be enabled explicitly with langfuse.allow-insecure-http-for-development=true; never use that option with production credentials.
<dependency>
<groupId>io.github.chomingi</groupId>
<artifactId>langfuse-otel-core</artifactId>
<version>0.2.0</version>
</dependency>try (LangfuseOtel langfuse = LangfuseOtel.builder()
.publicKey("pk-lf-...").secretKey("sk-lf-...")
.host("https://cloud.langfuse.com")
.serviceName("my-app").build()) {
langfuse.trace("my-flow", trace -> {
trace.userId("user-123").sessionId("session-456");
trace.generation("llm-call", gen -> {
gen.model("gpt-4o").input(prompt);
gen.output(callLLM(prompt)).inputTokens(52).outputTokens(85);
});
});
}With the default langfuse.otel-mode=auto, the starter reuses an application OpenTelemetry bean
when Spring can select one unambiguously: either it is the only bean or one candidate is marked
@Primary. With no application bean, auto creates the standalone pipeline. Multiple candidates
without a primary bean fail at startup.
When an application bean is selected, the starter does not create an exporter and never flushes or shuts down the application-owned SDK. Configure that SDK or an OpenTelemetry Collector to export traces to Langfuse. If Langfuse keys are also configured, the starter warns that they are ignored for the selected external pipeline.
For an OTLP/HTTP exporter configured through standard environment variables:
export AUTH_STRING="$(printf '%s' "${LANGFUSE_PUBLIC_KEY}:${LANGFUSE_SECRET_KEY}" | base64 | tr -d '\n')"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
export OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic ${AUTH_STRING},x-langfuse-ingestion-version=4"Configure equivalent endpoint and headers when building the SDK or Collector directly. The ingestion-version header enables real-time v4 ingestion; without it, directly ingested traces can be delayed. See Langfuse's OpenTelemetry guide for other regions, self-hosting, and signal-specific settings.
Version 0.2 emits observation-first attributes for Langfuse v4. Trace roots and generic steps are
span observations, chat/image calls and @ObserveGeneration are generation observations, and
embedding calls are embedding observations. Root .input(...) and .output(...) write the v4
observation fields as well as the legacy trace fields for compatibility.
Set langfuse.otel-mode=external to require one unambiguously selectable application bean, or
standalone to force a dedicated Langfuse SDK/exporter. external fails when no bean can be
selected; both auto and external reject ambiguous candidates instead of silently choosing a
telemetry pipeline.
Without Spring, select the same non-owning mode explicitly:
try (LangfuseOtel langfuse = LangfuseOtel.externalBuilder(openTelemetry).build()) {
langfuse.trace("my-flow", trace -> { /* ... */ });
}
// openTelemetry remains owned by the applicationThe original builder() remains the standalone mode: it creates and owns a dedicated SDK and Langfuse OTLP exporter.
The library applies Langfuse context directly to spans it creates. It cannot add a processor to an already-built external SDK, so raw application or third-party spans need their own baggage/span processor or explicit attributes if they must carry the same trace-wide fields.
The Spring Boot starter instruments calls made through eligible Spring-managed proxies that
implement the supported interfaces below. Objects constructed with new, direct provider-SDK
calls, and calls that bypass the Spring proxy are outside this boundary.
| Model | Methods | Operation |
|---|---|---|
ChatModel |
call(Prompt) |
chat |
ChatModel |
stream(Prompt) |
chat (with TTFT) |
EmbeddingModel |
call(EmbeddingRequest), document and bulk embed(...) |
embeddings |
ImageModel |
call(ImagePrompt) |
image_generation |
Reactor scheduler transitions and downstream signals are bridged automatically. Provider adapters
that own a raw, plain-thread source can place
ReactorContextPropagation.wrap(rawPublisher) at that source boundary. See the
async lifecycle design for the exact
boundary.
| Model | Methods | Operation |
|---|---|---|
ChatModel |
chat(ChatRequest) |
chat |
StreamingChatModel |
chat(ChatRequest, Handler) |
chat (with TTFT) |
EmbeddingModel |
embedAll(...), embed(...) |
embeddings |
ImageModel |
generate(...), edit(...) |
image_generation |
Streaming callbacks and model listeners restore the wrapper observation and immutable request
metadata. Provider adapters that control task submission can use
LangChain4jStreamingContext.wrap(...), taskWrapping(...), or a per-request Snapshot.
Opaque provider executors remain outside the generic SPI; the
async lifecycle design describes that
limit.
| Attribute | Description |
|---|---|
| Model name | Request & response model |
| Input | Disabled by default; messages, embedding text, or image prompt when opted in |
| Output | Disabled by default; response or accumulated stream when opted in |
| Token usage | Input, output, and total tokens |
| Temperature, top_p, max_tokens | Model parameters |
| TTFT | Time to first token (streaming only) |
| Errors | Exception type by default; policy-processed message/stack trace only after explicit opt-in |
Trace a proxy-interceptable Spring method as an LLM generation:
@Service
public class LLMService {
@ObserveGeneration(name = "summarize", model = "gpt-4o", system = "openai")
public String summarize(String text) {
return callLLM(text);
}
}@ObserveGeneration covers synchronous methods, CompletionStage, and Reactor Mono/Flux.
Stages retain their identity, and Reactor creates one observation per subscription. On a model
bean, explicit annotations take precedence over automatic instrumentation so the same call is not
traced twice. The method must be reached through its Spring proxy: self-invocation and private or
final methods are not advised. Ordering relative to other around advice, such as
@Transactional, is not guaranteed.
LangfuseTraceContext metadata = LangfuseTraceContext.builder()
.userId("user-123")
.sessionId("session-456")
.tags("prod", "v2")
.build();
try (Scope ignored = LangfuseContext.makeCurrent(metadata)) {
langfuse.trace("flow", trace -> { /* ... */ });
}Spring MVC and WebFlux filters can also extract Principal and HTTP session metadata after explicit
opt-in. The legacy LangfuseContext.set*() methods remain available for synchronous code.
Trace name, user/session IDs, tags, metadata, version, release, and environment are copied when a
library-created descendant starts. Set fluent trace-wide values before creating children when all
observations must agree; already-started spans are not backfilled. While a LangfuseTrace is
active, its trace-local carrier is authoritative, so nested storeIn(...) or makeCurrent(...)
metadata does not replace that trace. Use the trace fluent setters for intentional updates.
Automatic instrumentation is metadata-only by default. Enable input and output independently, and keep a finite post-redaction length limit:
langfuse:
content:
capture-input: true
capture-output: true
max-length: 8192To redact content before export, provide one thread-safe bean:
@Bean
ContentRedactor contentRedactor() {
return (type, content) -> content.replaceAll("(?i)api[-_ ]?key\\s*[:=]\\s*\\S+", "[REDACTED]");
}No redactor is installed automatically. If capture is enabled without one, values are exported unchanged except for the length limit. Use one reviewed, thread-safe redactor in production; ambiguous or failing redactors drop the affected automatic content.
Exception details use a separate safe-by-default policy. Only the exception type is recorded unless message or stack capture is enabled:
langfuse:
exception:
capture-message: false
capture-stack-trace: false
max-length: 8192Exception detail capture follows the same rule with ExceptionRedactor. See
SECURITY.md for the full data boundary and fail-closed behavior.
Integrates with langfuse-java for prompt versioning:
<dependency>
<groupId>com.langfuse</groupId>
<artifactId>langfuse-java</artifactId>
<version>0.2.0</version>
</dependency>The prompt client is optional and is not pulled in transitively. Create it with the same Langfuse credentials and host before using the helper:
LangfuseClient langfuseClient = LangfuseClient.builder()
.credentials("pk-lf-...", "sk-lf-...")
.url("https://cloud.langfuse.com")
.build();
trace.generation("llm", gen -> {
String compiled = gen.prompt(langfuseClient, "my-prompt")
.variable("domain", "HR")
.variable("question", "What is MBO?")
.compile();
// promptName & promptVersion auto-linked to the span
gen.output(callLLM(compiled));
});The core API supports callbacks, try-with-resources, and explicit end(). All three are
synchronous scope APIs: close a handle on the thread that created it and do not pass it into an
asynchronous callback.
With the default fail-safe setting, missing API keys or an invalid standalone configuration fall
back to no-op mode without crashing the application. LangfuseOtel.getStatus() returns an
immutable snapshot of that fallback, ownership, export, queue-drop, and flush state. Reading it
does not flush or make a network request.
When the application includes spring-boot-starter-actuator, the starter registers a langfuse health component and Micrometer meters. The Actuator dependency remains optional and is not added transitively by this library.
UP: the standalone pipeline is owned and has no current failure. This can include the initial state before the first export.DOWN: the latest export failed, a queue drop has not yet been followed by a successful export, or the latest flush failed or timed out.OUT_OF_SERVICE: fail-safe construction produced the library's no-op fallback.UNKNOWN: OpenTelemetry is application-owned, so its exporter, queue, and flush state are not observed here.
Owned pipelines publish fallback, export-failure, queue-drop, and flush meters. External mode omits transport meters because this library does not own that pipeline. A successful flush confirms a local SDK drain, not remote ingestion.
Keep this component out of liveness. Add it to readiness only when losing Langfuse delivery should intentionally stop the application from receiving traffic.
| Module | Java | Description |
|---|---|---|
langfuse-otel-core |
11+ | Core tracing library — no framework dependency |
langfuse-otel-spring-boot-starter |
17+ | Auto-config for Spring AI & LangChain4j |
| Property | Default | Description |
|---|---|---|
langfuse.public-key |
— | Standalone mode public key; not used with an external OTel bean |
langfuse.secret-key |
— | Standalone mode secret key; not used with an external OTel bean |
langfuse.host |
https://cloud.langfuse.com |
Standalone mode Langfuse host URL |
langfuse.allow-insecure-http-for-development |
false |
Allow a plaintext standalone HTTP endpoint on localhost or a literal loopback address only |
langfuse.service-name |
langfuse-app |
Standalone mode service name |
langfuse.environment |
— | Standalone resource environment (e.g., production) |
langfuse.release |
— | Standalone resource release version |
langfuse.enabled |
true |
Enable/disable all tracing |
langfuse.otel-mode |
auto |
auto, external, or standalone OpenTelemetry ownership selection |
langfuse.content.capture-input |
false |
Capture automatic model input |
langfuse.content.capture-output |
false |
Capture automatic model output |
langfuse.content.max-length |
8192 |
Maximum UTF-16 units retained after redaction |
langfuse.exception.capture-message |
false |
Capture policy-processed exception messages from automatic instrumentation |
langfuse.exception.capture-stack-trace |
false |
Capture policy-processed exception stack traces from automatic instrumentation |
langfuse.exception.max-length |
8192 |
Maximum UTF-16 units retained per exception detail after redaction |
langfuse.context.capture-user-id |
false |
Export the authenticated Principal name as user.id |
langfuse.context.capture-session-id |
false |
Export the HTTP session identifier as session.id; avoid this for bearer-style session IDs |
- Generic instrumentation cannot enter opaque provider-owned executors. Integrations that own a raw source or scheduling boundary must use the supplied context adapters.
- During active instrumented Reactor subscriptions, a keyed
Schedulers.onScheduleHookis process-global. Every Reactor scheduled task in the JVM performs a small decorator lookup during that window; unrelated tasks receive no Langfuse context, and the hook is removed after the last lease ends. - Async observations are terminal-driven and have no library timeout. A publisher, stage, or LangChain4j provider that never completes, errors, or is cancelled can leave its observation open; configure provider/application timeouts and cancellation.
- Final or otherwise non-proxyable model types are left unchanged and logged as uninstrumented. Annotation-based tracing also cannot advise self-invocation or private/final methods.
0.2.xis JVM-only; Spring AOT and GraalVM native-image support are not claimed.- Custom concrete publisher subtypes are returned unchanged when a compatible wrapper type cannot be preserved.
0.2.x remains a production-preview line. Work for later releases is tracked in
ROADMAP.md.
| Dependency | Tested Version | Notes |
|---|---|---|
| Java | 11+ | Core module |
| Java | 17+ | Spring Boot starter |
| OpenTelemetry SDK | 1.62.0 | Via BOM |
| Spring Boot | 3.5.16 | Non-web external and standalone consumer startup |
| Spring AI | 1.0.9 / 1.1.8 | Chat consumer smoke; adapter tests also cover streaming, embeddings, and images |
| LangChain4j | 1.0.0 / 1.18.0 | Chat consumer smoke; adapter tests also cover streaming, embeddings, and images |
| langfuse-java | 0.2.0 | Prompt management (optional) |
| Langfuse Cloud | Managed service | Current OTLP v4 ingestion and Observations API v2 |
| Langfuse Self-hosted | v3.22.0+ | OTLP endpoint minimum; v4 is required for observation-first API v2 read-back |
The blocking consumer smoke tests start a non-web Spring Boot 3.5.16 application on Java 17 and
invoke a framework ChatModel for both listed Spring AI and LangChain4j versions. External mode
verifies the resulting span with an application-owned SDK; standalone mode verifies the OTLP path,
authentication and ingestion headers, service resource, and model attributes against a loopback
receiver. The broader adapter behavior is covered by the Java 17/21 starter test matrix. Provider
clients and other Spring Boot 3 minor releases are not part of that runtime smoke.
0.2.x is the Spring Boot 3 and Spring AI 1 line. 0.3.x will move the same starter
coordinates to Spring Boot 4 and Spring AI 2; the core module remains framework-neutral. See
SECURITY.md for the maintenance window.
See the examples directory:
- Spring AI + OpenAI — automatic Spring bean tracing
- LangChain4j + OpenAI — automatic Spring bean tracing
See CONTRIBUTING.md for development setup and guidelines.
MIT