<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged langfuse</title>
<link>https://coderlegion.com/tag/langfuse</link>
<description>Powered by Question2Answer</description>
<item>
<title>Spring Boot 4 + Spring AI + Langfuse: Tracing LLM Calls and Tool Calling</title>
<link>https://coderlegion.com/28396/spring-boot-4-spring-ai-langfuse-tracing-llm-calls-and-tool-calling</link>
<description>&lt;h2 id=&quot;part-2-spring-ai-langfuse-tracing-streaming-with-observationfilter&quot;&gt;Part 2: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/lbobylev/spring-ai-langfuse-tracing-streaming-with-observationfilter-h3l&quot;&gt;Spring AI + Langfuse: Tracing Streaming with ObservationFilter&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;In this post, we will look at how to connect Spring AI with Langfuse and get complete tracing for LLM calls, tool calling, prompts, and responses.&lt;/p&gt;
&lt;p&gt;Below is a demo project built with Spring Boot and Spring AI: an HTTP request invokes an LLM, &lt;code&gt;WeatherTools&lt;/code&gt; is used when needed to fetch weather data from the internet, and traces, prompts, and responses are sent to Langfuse through OpenTelemetry and &lt;code&gt;CallAdvisor&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://langfuse.com/&quot;&gt;Langfuse&lt;/a&gt; is a platform for monitoring and evaluating AI applications. The experience is very similar to LangSmith: you can trace LLM calls, inspect inputs and outputs, track latency and cost, and compare prompts and models. Langfuse also has an open-source version and can be self-hosted, which makes it a good choice for teams that need more control over their data and infrastructure.&lt;/p&gt;
&lt;p&gt;The main dependencies in &lt;code&gt;build.gradle&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-gradle&quot;&gt;dependencyManagement {
    imports {
        mavenBom 'org.springframework.ai:spring-ai-bom:2.0.0'
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:4.1.0'
    implementation 'org.springframework.boot:spring-boot-starter-opentelemetry:4.1.0'
    implementation 'org.springframework.ai:spring-ai-starter-model-openai:2.0.0'

    compileOnly 'io.opentelemetry:opentelemetry-exporter-otlp:1.62.0'

    testImplementation 'org.springframework.boot:spring-boot-starter-test:4.1.0'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;application-configuration&quot;&gt;Application Configuration&lt;/h3&gt;
&lt;p&gt;In &lt;code&gt;application.properties&lt;/code&gt;, configure OpenAI, Spring AI observability, and the Langfuse OTLP endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-5
spring.ai.tools.observations.include-content=true
spring.ai.chat.client.observations.log-prompt=true
spring.ai.chat.client.observations.log-completion=true
spring.ai.chat.observations.log-prompt=true
spring.ai.chat.observations.log-completion=true

management.otlp.metrics.export.enabled=false
management.tracing.sampling.probability=1.0
management.tracing.export.otlp.enabled=${LANGFUSE_TRACING_ENABLED:false}
management.opentelemetry.tracing.export.otlp.endpoint=${LANGFUSE_OTLP_ENDPOINT:http://localhost:3000/api/public/otel}
management.opentelemetry.tracing.export.otlp.transport=http

langfuse.public-key=${LANGFUSE_PUBLIC_KEY:}
langfuse.secret-key=${LANGFUSE_SECRET_KEY:}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since Langfuse is used for tracing, it is better to disable OTLP metric export. Otherwise, the application may try to send metrics to the same endpoint and get a &lt;code&gt;ConnectException&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;management.otlp.metrics.export.enabled=false
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;controller&quot;&gt;Controller&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;LangfusePromptTracingAdvisor&lt;/code&gt; adds the prompt and response to the Langfuse trace.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@RestController
class HelloAiController {

    private final ChatClient chatClient;
    private final WeatherTools weatherTools;

    HelloAiController(
            ChatClient.Builder chatClientBuilder,
            WeatherTools weatherTools,
            LangfusePromptTracingAdvisor langfusePromptTracingAdvisor
    ) {
        this.chatClient = chatClientBuilder
                .defaultAdvisors(langfusePromptTracingAdvisor)
                .build();

        this.weatherTools = weatherTools;
    }

    @GetMapping(&quot;/ai&quot;)
    String ai(
            @RequestParam(defaultValue = &quot;Say hello in one short sentence&quot;)
            String message
    ) {
        return this.chatClient.prompt()
                .user(message)
                .tools(this.weatherTools)
                .call()
                .content();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;tools&quot;&gt;Tools&lt;/h3&gt;
&lt;p&gt;The tool uses Open-Meteo to find the coordinates of a city and retrieve the current weather for those coordinates.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
class WeatherTools {

    private final RestClient restClient;

    WeatherTools(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.build();
    }

    @Tool(description = &quot;Get current weather for a city using live internet weather data&quot;)
    String getCurrentWeather(
            @ToolParam(description = &quot;City name, for example Berlin or New York&quot;)
            String city
    ) {
        GeocodingResponse geocoding = this.restClient.get()
                .uri(
                        &quot;https://geocoding-api.open-meteo.com/v1/search&quot;
                                + &quot;?name={city}&amp;amp;count=1&amp;amp;language=en&amp;amp;format=json&quot;,
                        city
                )
                .retrieve()
                .body(GeocodingResponse.class);

        if (geocoding == null
                || geocoding.results() == null
                || geocoding.results().isEmpty()) {
            return &quot;Weather is unavailable: city not found: &quot; + city;
        }

        Location location = geocoding.results().getFirst();

        ForecastResponse forecast = this.restClient.get()
                .uri(
                        &quot;https://api.open-meteo.com/v1/forecast&quot;
                                + &quot;?latitude={latitude}&quot;
                                + &quot;&amp;amp;longitude={longitude}&quot;
                                + &quot;&amp;amp;current=temperature_2m,relative_humidity_2m,&quot;
                                + &quot;wind_speed_10m,weather_code&quot;,
                        location.latitude(),
                        location.longitude()
                )
                .retrieve()
                .body(ForecastResponse.class);

        if (forecast == null || forecast.current() == null) {
            return &quot;Weather is unavailable for &quot; + location.name();
        }

        CurrentWeather current = forecast.current();

        return &quot;Current weather in %s, %s: %.1f C, humidity %d%%, &quot;
                + &quot;wind %.1f km/h, weather code %d.&quot;
                .formatted(
                        location.name(),
                        location.country(),
                        current.temperature2m(),
                        current.relativeHumidity2m(),
                        current.windSpeed10m(),
                        current.weatherCode()
                );
    }

    private record GeocodingResponse(List&amp;lt;Location&amp;gt; results) {
    }

    private record Location(
            String name,
            String country,
            double latitude,
            double longitude
    ) {
    }

    private record ForecastResponse(CurrentWeather current) {
    }

    private record CurrentWeather(
            @tools.jackson.annotation.JsonProperty(&quot;temperature_2m&quot;)
            double temperature2m(),

            @tools.jackson.annotation.JsonProperty(&quot;relative_humidity_2m&quot;)
            int relativeHumidity2m(),

            @tools.jackson.annotation.JsonProperty(&quot;wind_speed_10m&quot;)
            double windSpeed10m(),

            @tools.jackson.annotation.JsonProperty(&quot;weather_code&quot;)
            int weatherCode()
    ) {
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;sending-traces-to-langfuse-through-opentelemetry&quot;&gt;Sending Traces to Langfuse Through OpenTelemetry&lt;/h3&gt;
&lt;p&gt;Spring Boot sends traces through the &lt;code&gt;spring-boot-starter-opentelemetry&lt;/code&gt; dependency. Langfuse requires Basic Authentication using the public and secret keys. The required environment variables are:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;export LANGFUSE_TRACING_ENABLED=true
export LANGFUSE_PUBLIC_KEY=pk-lf-...
export LANGFUSE_SECRET_KEY=sk-lf-...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A separate configuration adds the required HTTP headers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Configuration
class LangfuseTracingConfig {

    @Bean
    OtlpHttpSpanExporterBuilderCustomizer langfuseOtlpHeaders(
            @Value(&quot;${langfuse.public-key:}&quot;) String publicKey,
            @Value(&quot;${langfuse.secret-key:}&quot;) String secretKey
    ) {
        return builder -&amp;gt; {
            if (!StringUtils.hasText(publicKey)
                    || !StringUtils.hasText(secretKey)) {
                return;
            }

            String token = Base64.getEncoder()
                    .encodeToString(
                            (publicKey + &quot;:&quot; + secretKey)
                                    .getBytes(StandardCharsets.UTF_8)
                    );

            builder.addHeader(&quot;Authorization&quot;, &quot;Basic &quot; + token);
            builder.addHeader(&quot;x-langfuse-ingestion-version&quot;, &quot;4&quot;);
        };
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;adding-prompts-to-langfuse&quot;&gt;Adding Prompts to Langfuse&lt;/h3&gt;
&lt;p&gt;Spring AI does not put the prompt and completion into the Langfuse &lt;code&gt;input&lt;/code&gt; and &lt;code&gt;output&lt;/code&gt; fields in the required format. To solve this, we use the standard Spring AI &lt;code&gt;CallAdvisor&lt;/code&gt; mechanism.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
class LangfusePromptTracingAdvisor implements CallAdvisor {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public ChatClientResponse adviseCall(
            ChatClientRequest chatClientRequest,
            CallAdvisorChain callAdvisorChain
    ) {
        var span = Span.current();

        String input = serializePrompt(chatClientRequest);

        span.setAttribute(&quot;langfuse.observation.type&quot;, &quot;generation&quot;);
        span.setAttribute(&quot;input.value&quot;, input);
        span.setAttribute(&quot;langfuse.observation.input&quot;, input);

        ChatClientResponse chatClientResponse =
                callAdvisorChain.nextCall(chatClientRequest);

        String output =
                serializeResponse(chatClientResponse.chatResponse());

        span.setAttribute(&quot;output.value&quot;, output);
        span.setAttribute(&quot;langfuse.observation.output&quot;, output);

        return chatClientResponse;
    }

    @Override
    public String getName() {
        return &quot;Langfuse Prompt Tracing Advisor&quot;;
    }

    @Override
    public int getOrder() {
        return ToolCallingAdvisor.DEFAULT_ORDER + 100;
    }

    private String serializePrompt(ChatClientRequest request) {
        var messages = request.prompt()
                .getInstructions()
                .stream()
                .map(message -&amp;gt; Map.of(
                        &quot;role&quot;, message.getMessageType().getValue(),
                        &quot;content&quot;, ofNullable(message.getText()).orElse(&quot;&quot;)))
                .toList();
        return toJson(messages);
    }

    private String serializeResponse(ChatResponse response) {
        return ofNullable(response).map(ChatResponse::getResult)
                .map(Generation::getOutput)
                .map(output -&amp;gt; toJson(

                        Map.of(&quot;role&quot;, output.getMessageType().getValue(),
                                &quot;content&quot;, ofNullable(output.getText()).orElse(&quot;&quot;),
                                &quot;hasToolCalls&quot;, response.hasToolCalls())

                ))
                .orElse(&quot;&quot;);
    }

    private String toJson(Object value) {
        try {
            return this.objectMapper.writeValueAsString(value);
        }
        catch (JsonProcessingException ex) {
            return String.valueOf(value);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;According to the Spring AI documentation, &lt;code&gt;ToolCallingAdvisor&lt;/code&gt; manages the tool-calling loop itself, while &lt;code&gt;getOrder()&lt;/code&gt; determines the advisor's position in the chain. Setting it to &lt;code&gt;ToolCallingAdvisor.DEFAULT_ORDER + 100&lt;/code&gt; places our advisor inside the tool-calling loop, so it runs on every iteration and can observe the original request, the model response that decides to call a tool, the following request containing the tool result, and the final model response.&lt;/p&gt;
&lt;h3 id=&quot;what-you-will-see-in-langfuse&quot;&gt;What You Will See in Langfuse&lt;/h3&gt;
&lt;p&gt;Example request to the application:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;curl &quot;http://localhost:8080/ai?message=What%20is%20the%20weather%20in%20Berlin?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Langfuse should display a trace with spans.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ij8eavci8ks23o5mnpx7.png&quot; alt=&quot;Tool calling&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/asyt0mux9xnbx6xf3hgz.png&quot; alt=&quot;Prompt tracing&quot;&gt;&lt;/p&gt;
&lt;p&gt;Generation spans will contain attributes such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;langfuse.observation.type=generation
langfuse.observation.input=...
langfuse.observation.output=...
input.value=...
output.value=...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When the following option is enabled, Spring AI exports tool call arguments and results:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-properties&quot;&gt;spring.ai.tools.observations.include-content=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;production-considerations&quot;&gt;Production Considerations&lt;/h3&gt;
&lt;p&gt;Prompts, completions, and tool results may contain sensitive data. Traces are also typically stored longer than the application's runtime memory. For production, it is worth adding content truncation, sanitization, secret removal, and PII filtering.&lt;/p&gt;
&lt;p&gt;Combining Spring AI Advisors with Langfuse through OTLP provides transparent tracing of LLM calls, prompts, responses, tool calls, and intermediate steps.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28396/spring-boot-4-spring-ai-langfuse-tracing-llm-calls-and-tool-calling</guid>
<pubDate>Wed, 23 Sep 2026 21:53:21 +0000</pubDate>
</item>
</channel>
</rss>